From 48828a2d61188845784f5450667e13ee81ef7daf Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 28 Sep 2026 11:25:36 +0200 Subject: [PATCH 1/3] fix(solvers): probe the Xpress licence, run solving tests on licensed solvers (#994) --- doc/release_notes.rst | 1 + linopy/solvers.py | 4 ++++ test/test_available_solvers.py | 20 ++++++++++++++++++ test/test_constraints.py | 6 +++--- test/test_csr.py | 6 +++--- test/test_expressions.py | 6 +++--- test/test_indicator_constraints.py | 4 ++-- test/test_infeasibility.py | 18 ++++++++-------- test/test_io.py | 34 +++++++++++++++--------------- test/test_mi_feasibility.py | 4 ++-- test/test_model.py | 4 ++-- test/test_piecewise_active_fill.py | 6 +++--- test/test_piecewise_constraints.py | 6 +++--- test/test_piecewise_feasibility.py | 6 +++--- test/test_scaling.py | 4 ++-- test/test_semi_continuous.py | 12 +++++------ test/test_solvers.py | 8 +++---- test/test_sos_constraints.py | 20 +++++++++--------- test/test_sos_masked.py | 6 +++--- test/test_sos_reformulation.py | 32 +++++++++++++--------------- test/test_sos_weights.py | 4 ++-- 21 files changed, 117 insertions(+), 94 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 42e8bee49..fa7e53d8d 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -92,6 +92,7 @@ Upcoming Version * A frozen constraint caches the label-to-position mapping of its matrix columns by weak reference and only rebuilds it when the constraint or the set of variables changes. While a persistent snapshot holds the arrays, repeated matrix assembly on an unchanged model returns the same objects, so the snapshot diff can again skip the comparison of untouched frozen constraints by object identity; one-off exports such as ``to_file`` retain no extra memory. (`#933 `__) * ``Model.copy`` (and the ``copy.copy``/``copy.deepcopy`` protocols) keeps frozen constraints as ``CSRConstraint`` instead of rebuilding them as dense ``Constraint``; ``deep=True`` copies their arrays, and duals are copied only with ``include_solution=True``. A CSR-backed objective is now deep-copied with ``deep=True`` as well. The auxiliary coordinates of a CSR grid are stored read-only, so a copy can share them safely. (`#981 `__) * ``Solver.close()`` no longer leaves dangling native handles behind. The solver model is now dropped before the environment that owns it, instead of after. And the COPT and MindOpt file interfaces no longer hand back a model they already disposed: after a file-based COPT or MindOpt solve, ``model.solver_model`` is ``None`` rather than a handle into freed memory. (`#899 `__) +* ``licensed_solvers`` and ``check_solver_licenses()`` no longer report Xpress as licensed when its licence is rejected, e.g. a licence that only covers an older Xpress release. Xpress now probes its licence with ``xpress.init()``. (`#994 `__) **Breaking Changes** diff --git a/linopy/solvers.py b/linopy/solvers.py index 6e1ec74d0..0710db845 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2665,6 +2665,10 @@ class Xpress(Solver[None]): def is_available(cls) -> bool: return _has_module("xpress") + @classmethod + def _license_probe(cls) -> None: + xpress.init() + def _apply_var_bounds( self, ctx: Any, indices: np.ndarray, lower: np.ndarray, upper: np.ndarray ) -> None: diff --git a/test/test_available_solvers.py b/test/test_available_solvers.py index 549d173e4..da2f9f4cd 100644 --- a/test/test_available_solvers.py +++ b/test/test_available_solvers.py @@ -238,3 +238,23 @@ class _FakeMindoptpy: cls._license_probe() assert events == ["env_init", "env_dispose"] + + +def test_xpress_license_probe_failure_marks_unlicensed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cls = _solver_class_for("xpress") + assert cls is not None + monkeypatch.setattr(cls, "is_available", classmethod(lambda c: True)) + + class _FakeXpress: + @staticmethod + def init() -> None: + raise RuntimeError("licensing error 21") + + monkeypatch.setattr(solvers_mod, "xpress", _FakeXpress, raising=False) + + status = cls.license_status() + + assert status.ok is False + assert "licensing error 21" in (status.message or "") diff --git a/test/test_constraints.py b/test/test_constraints.py index 53a7aa669..d60f72710 100644 --- a/test/test_constraints.py +++ b/test/test_constraints.py @@ -14,7 +14,7 @@ import pytest import xarray as xr -from linopy import EQUAL, GREATER_EQUAL, LESS_EQUAL, Model, Variable, available_solvers +from linopy import EQUAL, GREATER_EQUAL, LESS_EQUAL, Model, Variable, licensed_solvers from linopy.testing import assert_conequal # Test model functions @@ -490,9 +490,9 @@ def test_constraint_rhs_extra_dims_broadcasts(self, v: Variable) -> None: assert "extra" in c.dims def test_subset_constraint_solve_integration(self) -> None: - if not available_solvers: + if not licensed_solvers: pytest.skip("No solver available") - solver = "highs" if "highs" in available_solvers else available_solvers[0] + solver = "highs" if "highs" in licensed_solvers else licensed_solvers[0] m = Model() coords = pd.RangeIndex(5, name="i") x = m.add_variables(lower=0, upper=100, coords=[coords], name="x") diff --git a/test/test_csr.py b/test/test_csr.py index a3349105e..ccdc28cd6 100644 --- a/test/test_csr.py +++ b/test/test_csr.py @@ -1745,7 +1745,7 @@ def test_objective_stays_csr_and_exports_like_dense( assert rs.objective.expression.attrs["name"] == "objective" -@pytest.mark.skipif("highs" not in linopy.available_solvers, reason="needs highs") +@pytest.mark.skipif("highs" not in linopy.licensed_solvers, reason="needs highs") @pytest.mark.parametrize("io_api", ["lp", "direct"]) @pytest.mark.parametrize("build", list(SPARSE_BUILDS)) def test_sparse_objective_solves_like_dense(build: str, io_api: str) -> None: @@ -2380,7 +2380,7 @@ def test_frozen_soften_max_sense_with_array_penalty() -> None: assert_cells_equal(obj, expected_objective, ()) -@pytest.mark.skipif("highs" not in linopy.available_solvers, reason="needs highs") +@pytest.mark.skipif("highs" not in linopy.licensed_solvers, reason="needs highs") @pytest.mark.parametrize("sign", ["<=", ">=", "=="]) def test_frozen_soften_solves_like_dense(sign: str) -> None: require_v1() @@ -2454,7 +2454,7 @@ def frozen_model(soften: bool) -> tuple[Model, CSRConstraint]: return c.m, con -@pytest.mark.skipif("highs" not in linopy.available_solvers, reason="needs highs") +@pytest.mark.skipif("highs" not in linopy.licensed_solvers, reason="needs highs") @pytest.mark.parametrize("include_solution", [True, False]) @pytest.mark.parametrize("deep", [True, False]) def test_copy_keeps_frozen_constraints(deep: bool, include_solution: bool) -> None: diff --git a/test/test_expressions.py b/test/test_expressions.py index 53c5a57ce..003bc7721 100644 --- a/test/test_expressions.py +++ b/test/test_expressions.py @@ -9,7 +9,7 @@ from linopy import Model from linopy.expressions import Expressions, LinearExpression, QuadraticExpression -from linopy.solvers import available_solvers +from linopy.solvers import licensed_solvers from linopy.testing import assert_linequal @@ -128,14 +128,14 @@ def test_model_repr_contains_expressions(m: Model) -> None: assert "* expr_x" in r -@pytest.mark.skipif(not available_solvers, reason="No solver available") +@pytest.mark.skipif(not licensed_solvers, reason="No solver available") def test_expressions_solution() -> None: m = Model() x = m.add_variables(lower=0, coords=[pd.RangeIndex(3, name="first")], name="x") m.add_constraints(x >= 2) m.add_expressions(2 * x, name="double_x") m.add_objective(x.sum()) - m.solve(available_solvers[0]) + m.solve(licensed_solvers[0]) sol = m.expressions.solution assert isinstance(sol, xr.Dataset) diff --git a/test/test_indicator_constraints.py b/test/test_indicator_constraints.py index 745f97b41..eecdad3e2 100644 --- a/test/test_indicator_constraints.py +++ b/test/test_indicator_constraints.py @@ -7,12 +7,12 @@ import pytest import linopy -from linopy import Model, available_solvers +from linopy import Model, available_solvers, licensed_solvers from linopy.constraints import Constraint, CSRConstraint from linopy.variables import Variable requires_gurobi = pytest.mark.skipif( - "gurobi" not in available_solvers, reason="Gurobi not installed" + "gurobi" not in licensed_solvers, reason="Gurobi not installed" ) diff --git a/test/test_infeasibility.py b/test/test_infeasibility.py index bee4fb334..c2e04e451 100644 --- a/test/test_infeasibility.py +++ b/test/test_infeasibility.py @@ -8,7 +8,7 @@ import pandas as pd import pytest -from linopy import Model, available_solvers +from linopy import Model, licensed_solvers class TestInfeasibility: @@ -80,7 +80,7 @@ def test_simple_infeasibility_detection( self, simple_infeasible_model: Model, solver: str ) -> None: """Test basic infeasibility detection.""" - if solver not in available_solvers: + if solver not in licensed_solvers: pytest.skip(f"{solver} not available") m = simple_infeasible_model @@ -103,7 +103,7 @@ def test_complex_infeasibility_detection( self, complex_infeasible_model: Model, solver: str ) -> None: """Test infeasibility detection on more complex model.""" - if solver not in available_solvers: + if solver not in licensed_solvers: pytest.skip(f"{solver} not available") m = complex_infeasible_model @@ -126,7 +126,7 @@ def test_multi_dimensional_infeasibility( self, multi_dimensional_infeasible_model: Model, solver: str ) -> None: """Test infeasibility detection on multi-dimensional model.""" - if solver not in available_solvers: + if solver not in licensed_solvers: pytest.skip(f"{solver} not available") m = multi_dimensional_infeasible_model @@ -155,7 +155,7 @@ def test_unsolved_model_error(self) -> None: @pytest.mark.parametrize("solver", ["gurobi", "xpress", "highs"]) def test_no_solver_model_error(self, solver: str) -> None: """Test error when solver model is not available after solving.""" - if solver not in available_solvers: + if solver not in licensed_solvers: pytest.skip(f"{solver} not available") m = Model() @@ -176,7 +176,7 @@ def test_no_solver_model_error(self, solver: str) -> None: @pytest.mark.parametrize("solver", ["gurobi", "xpress", "highs"]) def test_feasible_model_iis(self, solver: str) -> None: """Test IIS computation on a feasible model.""" - if solver not in available_solvers: + if solver not in licensed_solvers: pytest.skip(f"{solver} not available") m = Model() @@ -213,7 +213,7 @@ def test_unsupported_solver_error(self) -> None: m.add_objective(1 * x) # Use a solver that doesn't support IIS - if "cbc" in available_solvers: + if "cbc" in licensed_solvers: status, condition = m.solve(solver_name="cbc") assert "infeasible" in condition @@ -225,7 +225,7 @@ def test_deprecated_method( self, simple_infeasible_model: Model, solver: str ) -> None: """Test that deprecated method still works.""" - if solver not in available_solvers: + if solver not in licensed_solvers: pytest.skip(f"{solver} not available") m = simple_infeasible_model @@ -263,7 +263,7 @@ def test_masked_constraint_infeasibility( The enumeration creates positions [0, 1, 2, ...] that should correspond to the actual constraint labels which may have gaps like [0, 2, 4, 6]. """ - if solver not in available_solvers: + if solver not in licensed_solvers: pytest.skip(f"{solver} not available") m = Model() diff --git a/test/test_io.py b/test/test_io.py index 67919367b..29f208770 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -18,7 +18,7 @@ import pytest import xarray as xr -from linopy import LESS_EQUAL, Model, available_solvers, read_netcdf +from linopy import LESS_EQUAL, Model, available_solvers, licensed_solvers, read_netcdf from linopy.constants import FACTOR_DIM from linopy.expressions import LinearExpression, QuadraticExpression from linopy.io import CONTAINER_ORDER_ATTR, signed_number @@ -552,7 +552,7 @@ def test_read_netcdf_without_version_stamp(model: Model, tmp_path: Path) -> None assert_model_equal(model, read_netcdf(fn_legacy)) -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed") def test_to_file_lp(model: Model, tmp_path: Path) -> None: import gurobipy @@ -562,7 +562,7 @@ def test_to_file_lp(model: Model, tmp_path: Path) -> None: gurobipy.read(str(fn)) -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed") def test_to_file_lp_explicit_coordinate_names(model: Model, tmp_path: Path) -> None: import gurobipy @@ -572,7 +572,7 @@ def test_to_file_lp_explicit_coordinate_names(model: Model, tmp_path: Path) -> N gurobipy.read(str(fn)) -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed") def test_to_file_lp_None(model: Model) -> None: import gurobipy @@ -584,7 +584,7 @@ def test_to_file_lp_None(model: Model) -> None: @pytest.mark.skipif( - not {"gurobi", "highs"}.issubset(available_solvers), + not {"gurobi", "highs"}.issubset(licensed_solvers), reason="Gurobipy of highspy not installed", ) def test_to_file_mps(model: Model, tmp_path: Path) -> None: @@ -603,25 +603,25 @@ def test_to_file_invalid(model: Model, tmp_path: Path) -> None: model.to_file(fn) -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed") def test_to_gurobipy(model: Model) -> None: gm = model.to_gurobipy() assert gm.NumVars > 0 -@pytest.mark.skipif("highs" not in available_solvers, reason="Highspy not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="Highspy not installed") def test_to_highspy(model: Model) -> None: h = model.to_highspy() assert h.getLp().num_col_ > 0 -@pytest.mark.skipif("mosek" not in available_solvers, reason="Mosek not installed") +@pytest.mark.skipif("mosek" not in licensed_solvers, reason="Mosek not installed") def test_to_mosek(model: Model) -> None: task = model.to_mosek() assert task.getnumvar() > 0 -@pytest.mark.skipif("xpress" not in available_solvers, reason="Xpress not installed") +@pytest.mark.skipif("xpress" not in licensed_solvers, reason="Xpress not installed") def test_to_xpress(model: Model) -> None: p = model.to_xpress() assert p.attributes.cols > 0 @@ -675,7 +675,7 @@ def test_to_solver_set_names( set_names: bool | None, expected: bool, ) -> None: - if solver not in available_solvers: + if solver not in licensed_solvers: pytest.skip(f"{solver} not installed") to_solver, names = SOLVER_IO[solver] named = to_solver(model, set_names=True) @@ -684,13 +684,13 @@ def test_to_solver_set_names( assert (names(built) == names(named)) == expected -@pytest.mark.skipif("cupdlpx" not in available_solvers, reason="cuPDLPx not installed") +@pytest.mark.skipif("cupdlpx" not in licensed_solvers, reason="cuPDLPx not installed") def test_to_cupdlpx(model: Model) -> None: cu = model.to_cupdlpx() assert cu is not None -@pytest.mark.skipif("cuopt" not in available_solvers, reason="cuOpt not installed") +@pytest.mark.skipif("cuopt" not in licensed_solvers, reason="cuOpt not installed") def test_to_cuopt(model: Model) -> None: dm = model.to_cuopt() assert len(dm.get_objective_coefficients()) > 0 @@ -701,7 +701,7 @@ def test_model_set_names_in_solver_io_default() -> None: assert Model().set_names_in_solver_io is False -@pytest.mark.skipif("highs" not in available_solvers, reason="Highspy not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="Highspy not installed") def test_model_set_names_in_solver_io(model: Model) -> None: model.solve(solver_name="highs", io_api="direct") expected_obj = model.objective.value @@ -781,7 +781,7 @@ def test_mixed_values_including_negative_zero(self) -> None: assert "+-" not in v -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed") def test_to_file_lp_with_negative_zero_bounds(tmp_path: Path) -> None: """ Test that LP files with negative zero bounds are valid. @@ -815,7 +815,7 @@ def test_to_file_lp_with_negative_zero_bounds(tmp_path: Path) -> None: gurobipy.read(str(fn)) -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed") def test_to_file_lp_with_negative_zero_coefficients(tmp_path: Path) -> None: """ Test that LP files with negative zero coefficients are valid. @@ -926,12 +926,12 @@ def test_tightened_bounds_written( for label in m.variables["x"].labels.values[2:]: assert f"x{label} <= +0.0" in bounds_section - @pytest.mark.skipif(not available_solvers, reason="No solver installed") + @pytest.mark.skipif(not licensed_solvers, reason="No solver installed") def test_lp_and_direct_agree( self, make_tightened_model: Callable[[], Model] ) -> None: """LP and direct paths see the same feasible set for tightened binaries.""" - solver = available_solvers[0] + solver = licensed_solvers[0] m_direct = make_tightened_model() m_direct.solve(solver_name=solver, io_api="direct") diff --git a/test/test_mi_feasibility.py b/test/test_mi_feasibility.py index 1438fb5fb..71dd16aa5 100644 --- a/test/test_mi_feasibility.py +++ b/test/test_mi_feasibility.py @@ -21,7 +21,7 @@ import pytest import xarray as xr -from linopy import Model, Variable, available_solvers +from linopy import Model, Variable, licensed_solvers from linopy.testing import assert_linequal PERIODS = [2020, 2030] @@ -31,7 +31,7 @@ DEMAND = {2020: 5.0, 2030: 7.0} needs_highs = pytest.mark.skipif( - "highs" not in available_solvers, reason="highs solver not available" + "highs" not in licensed_solvers, reason="highs solver not available" ) diff --git a/test/test_model.py b/test/test_model.py index d3f32928a..442b777d3 100644 --- a/test/test_model.py +++ b/test/test_model.py @@ -16,7 +16,7 @@ import pytest import xarray as xr -from linopy import EQUAL, Model, available_solvers +from linopy import EQUAL, Model, licensed_solvers from linopy.testing import ( assert_conequal, assert_equal, @@ -425,7 +425,7 @@ def test_model_copy_preserves_quadratic_objective( assert_model_equal(m, c) -@pytest.mark.skipif(not available_solvers, reason="No solver installed") +@pytest.mark.skipif(not licensed_solvers, reason="No solver installed") class TestModelCopySolved: def test_model_deepcopy_protocol_excludes_solution( self, solved_copy_test_model: Model diff --git a/test/test_piecewise_active_fill.py b/test/test_piecewise_active_fill.py index 0af04c8c3..df8cc7ca1 100644 --- a/test/test_piecewise_active_fill.py +++ b/test/test_piecewise_active_fill.py @@ -18,7 +18,7 @@ import pytest import xarray as xr -from linopy import Model, available_solvers, segments +from linopy import Model, licensed_solvers, segments from linopy.piecewise import _resolve_active from linopy.solver_capabilities import ( SolverFeature, @@ -29,10 +29,10 @@ GateBuilder: TypeAlias = Callable[[Model], Any] _any_solvers = [ - s for s in ["highs", "gurobi", "glpk", "cplex"] if s in available_solvers + s for s in ["highs", "gurobi", "glpk", "cplex"] if s in licensed_solvers ] _sos2_solvers = get_available_solvers_with_feature( - SolverFeature.SOS_CONSTRAINTS, available_solvers + SolverFeature.SOS_CONSTRAINTS, licensed_solvers ) diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 788a0674e..eee3808fc 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -15,8 +15,8 @@ from linopy import ( Model, - available_solvers, breakpoints, + licensed_solvers, segments, tangent_lines, ) @@ -54,7 +54,7 @@ Method: TypeAlias = Literal["sos2", "incremental", "lp", "auto"] _sos2_solvers = get_available_solvers_with_feature( - SolverFeature.SOS_CONSTRAINTS, available_solvers + SolverFeature.SOS_CONSTRAINTS, licensed_solvers ) _sos2_direct_solvers = sorted( s for s in _sos2_solvers if solver_supports(s, SolverFeature.DIRECT_API) @@ -64,7 +64,7 @@ *[pytest.param(s, "lp", id=f"{s}-lp") for s in sorted(_sos2_solvers)], ] _any_solvers = [ - s for s in ["highs", "gurobi", "glpk", "cplex"] if s in available_solvers + s for s in ["highs", "gurobi", "glpk", "cplex"] if s in licensed_solvers ] # Solver-output tolerance for solution-value assertions in this file. Matches diff --git a/test/test_piecewise_feasibility.py b/test/test_piecewise_feasibility.py index ed5dd49b8..e48c09647 100644 --- a/test/test_piecewise_feasibility.py +++ b/test/test_piecewise_feasibility.py @@ -28,7 +28,7 @@ import numpy as np import pytest -from linopy import Model, available_solvers +from linopy import Model, licensed_solvers from linopy.solver_capabilities import ( SolverFeature, get_available_solvers_with_feature, @@ -43,10 +43,10 @@ Y_LO, Y_HI = -100.0, 100.0 _sos2_solvers = get_available_solvers_with_feature( - SolverFeature.SOS_CONSTRAINTS, available_solvers + SolverFeature.SOS_CONSTRAINTS, licensed_solvers ) _any_solvers = [ - s for s in ["highs", "gurobi", "glpk", "cplex"] if s in available_solvers + s for s in ["highs", "gurobi", "glpk", "cplex"] if s in licensed_solvers ] pytestmark = pytest.mark.skipif( diff --git a/test/test_scaling.py b/test/test_scaling.py index 16c318421..368d2a271 100644 --- a/test/test_scaling.py +++ b/test/test_scaling.py @@ -11,7 +11,7 @@ from linopy import Model, read_netcdf from linopy.constants import Result, Solution, Status from linopy.constraints import CSRConstraint -from linopy.solvers import available_solvers +from linopy.solvers import licensed_solvers def _dense(matrix: Any) -> np.ndarray: @@ -275,7 +275,7 @@ def test_constraint_scaling_setter_broadcasts_to_rows() -> None: np.testing.assert_allclose(m.matrices.b, [2.0, 2.0]) -@pytest.mark.skipif("highs" not in available_solvers, reason="HiGHS is not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="HiGHS is not installed") def test_scaled_solve_preserves_user_units() -> None: reference = Model() rx = reference.add_variables(lower=0, name="x") diff --git a/test/test_semi_continuous.py b/test/test_semi_continuous.py index 915ae420d..b9796e75a 100644 --- a/test/test_semi_continuous.py +++ b/test/test_semi_continuous.py @@ -7,10 +7,10 @@ import pandas as pd import pytest -from linopy import Model, available_solvers +from linopy import Model, available_solvers, licensed_solvers requires_cuopt_gpu = pytest.mark.skipif( - not os.environ.get("LINOPY_RUN_GPU_TESTS") or "cuopt" not in available_solvers, + not os.environ.get("LINOPY_RUN_GPU_TESTS") or "cuopt" not in licensed_solvers, reason="need --run-gpu and an installed cuOpt with a usable GPU", ) @@ -103,7 +103,7 @@ def test_semi_continuous_with_coords() -> None: assert list(m.variables.semi_continuous) == ["x"] -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobi not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobi not installed") def test_semi_continuous_solve_gurobi() -> None: """ Semi-continuous variable solves correctly with Gurobi. @@ -121,7 +121,7 @@ def test_semi_continuous_solve_gurobi() -> None: assert np.isclose(m.objective.value, 0, atol=1e-6) -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobi not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobi not installed") def test_semi_continuous_solve_gurobi_active() -> None: """ Semi-continuous variable takes value in [lb, ub] when beneficial. @@ -151,7 +151,7 @@ def test_unsupported_solver_raises() -> None: m.solve(solver_name=solver) -@pytest.mark.skipif("highs" not in available_solvers, reason="HiGHS not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="HiGHS not installed") def test_semi_continuous_solve_highs() -> None: """ Semi-continuous variable solves correctly with HiGHS. @@ -169,7 +169,7 @@ def test_semi_continuous_solve_highs() -> None: assert np.isclose(m.objective.value, 0, atol=1e-6) -@pytest.mark.skipif("highs" not in available_solvers, reason="HiGHS not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="HiGHS not installed") def test_semi_continuous_solve_highs_active() -> None: """ Semi-continuous variable takes value in [lb, ub] when beneficial with HiGHS. diff --git a/test/test_solvers.py b/test/test_solvers.py index 4a90b6a0c..a83d257a4 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -578,7 +578,7 @@ def test_semi_continuous_without_support_raises(self, lp_only_solver: str) -> No solvers.Solver.from_name(lp_only_solver, m, io_api="lp") @pytest.mark.skipif( - "highs" not in solvers.available_solvers, reason="HiGHS not installed" + "highs" not in solvers.licensed_solvers, reason="HiGHS not installed" ) def test_solve_without_objective_raises(self) -> None: m = Model() @@ -594,7 +594,7 @@ class TestSolverDoesNotMutateModel: """Solver.from_model() must not mutate model state (sanitize stays Model-level).""" @pytest.mark.skipif( - "highs" not in solvers.available_solvers, reason="HiGHS not installed" + "highs" not in solvers.licensed_solvers, reason="HiGHS not installed" ) def test_from_model_leaves_constraints_untouched(self) -> None: m = Model() @@ -620,7 +620,7 @@ class TestAssignResultWiring: """assign_result(result, solver=...) populates model.solver.""" @pytest.mark.skipif( - "highs" not in solvers.available_solvers, reason="HiGHS not installed" + "highs" not in solvers.licensed_solvers, reason="HiGHS not installed" ) def test_assign_result_with_solver_wires_model_solver(self) -> None: m = Model() @@ -636,7 +636,7 @@ def test_assign_result_with_solver_wires_model_solver(self) -> None: assert m.solver_model is solver.solver_model @pytest.mark.skipif( - "highs" not in solvers.available_solvers, reason="HiGHS not installed" + "highs" not in solvers.licensed_solvers, reason="HiGHS not installed" ) def test_assign_result_without_solver_kwarg_leaves_solver_unset(self) -> None: m = Model() diff --git a/test/test_sos_constraints.py b/test/test_sos_constraints.py index 330e6eba5..42ea75f8d 100644 --- a/test/test_sos_constraints.py +++ b/test/test_sos_constraints.py @@ -7,7 +7,7 @@ import pytest import xarray as xr -from linopy import Model, available_solvers +from linopy import Model, available_solvers, licensed_solvers def test_add_sos_constraints_registers_variable() -> None: @@ -61,7 +61,7 @@ def test_sos_constraints_written_to_lp(tmp_path: Path) -> None: assert "3.5" in content -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobipy not installed") def test_to_gurobipy_emits_sos_constraints() -> None: gurobipy = pytest.importorskip("gurobipy") @@ -78,7 +78,7 @@ def test_to_gurobipy_emits_sos_constraints() -> None: assert model.NumSOS == 1 -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobi not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobi not installed") def test_sos1_binary_maximize_lp_polars() -> None: gurobipy = pytest.importorskip("gurobipy") @@ -98,7 +98,7 @@ def test_sos1_binary_maximize_lp_polars() -> None: assert np.isclose(m.objective.value, 3) -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobi not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobi not installed") def test_sos2_binary_maximize_direct() -> None: gurobipy = pytest.importorskip("gurobipy") @@ -118,7 +118,7 @@ def test_sos2_binary_maximize_direct() -> None: assert np.isclose(m.objective.value, 5) -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobi not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobi not installed") def test_sos2_binary_maximize_different_coeffs() -> None: gurobipy = pytest.importorskip("gurobipy") @@ -138,7 +138,7 @@ def test_sos2_binary_maximize_different_coeffs() -> None: assert np.isclose(m.objective.value, 4) -@pytest.mark.skipif("xpress" not in available_solvers, reason="Xpress not installed") +@pytest.mark.skipif("xpress" not in licensed_solvers, reason="Xpress not installed") def test_to_xpress_emits_sos_constraints() -> None: m = Model() segments = pd.Index([0.0, 0.5, 1.0], name="seg") @@ -150,7 +150,7 @@ def test_to_xpress_emits_sos_constraints() -> None: assert problem.attributes.sets == 1 -@pytest.mark.skipif("xpress" not in available_solvers, reason="Xpress not installed") +@pytest.mark.skipif("xpress" not in licensed_solvers, reason="Xpress not installed") def test_to_xpress_emits_grouped_sos_constraints() -> None: m = Model() groups = pd.Index(["a", "b"], name="group") @@ -163,7 +163,7 @@ def test_to_xpress_emits_grouped_sos_constraints() -> None: assert problem.attributes.sets == len(groups) -@pytest.mark.skipif("xpress" not in available_solvers, reason="Xpress not installed") +@pytest.mark.skipif("xpress" not in licensed_solvers, reason="Xpress not installed") def test_sos2_xpress_direct() -> None: m = Model() locations = pd.Index([0, 1, 2], name="locations") @@ -178,7 +178,7 @@ def test_sos2_xpress_direct() -> None: assert np.isclose(m.objective.value, 5) -@pytest.mark.skipif("xpress" not in available_solvers, reason="Xpress not installed") +@pytest.mark.skipif("xpress" not in licensed_solvers, reason="Xpress not installed") def test_qp_sos1_xpress_direct() -> None: m = Model() seg = pd.Index([0, 1, 2], name="seg") @@ -196,7 +196,7 @@ def test_qp_sos1_xpress_direct() -> None: assert np.isclose(m.objective.value, -25) -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobi not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobi not installed") def test_reformulate_sos_true_reformulates_on_native_solver(tmp_path: Path) -> None: """ ``reformulate_sos=True`` must reformulate even when the solver supports SOS. diff --git a/test/test_sos_masked.py b/test/test_sos_masked.py index 46906ba14..a58177964 100644 --- a/test/test_sos_masked.py +++ b/test/test_sos_masked.py @@ -28,7 +28,7 @@ import pytest import xarray as xr -from linopy import Model, available_solvers +from linopy import Model, licensed_solvers from linopy.solver_capabilities import SolverFeature, solver_supports # --------------------------------------------------------------------------- @@ -37,12 +37,12 @@ SOS_DIRECT = sorted( s - for s in available_solvers + for s in licensed_solvers if solver_supports(s, SolverFeature.SOS_CONSTRAINTS) and solver_supports(s, SolverFeature.DIRECT_API) ) SOS_FILE = sorted( - s for s in available_solvers if solver_supports(s, SolverFeature.SOS_CONSTRAINTS) + s for s in licensed_solvers if solver_supports(s, SolverFeature.SOS_CONSTRAINTS) ) SOS_PATHS = [ *[pytest.param(s, "direct", id=f"{s}-direct") for s in SOS_DIRECT], diff --git a/test/test_sos_reformulation.py b/test/test_sos_reformulation.py index 0e9dc9da6..81e1ce589 100644 --- a/test/test_sos_reformulation.py +++ b/test/test_sos_reformulation.py @@ -13,7 +13,7 @@ import pytest import xarray as xr -from linopy import Model, Variable, available_solvers +from linopy import Model, Variable, licensed_solvers from linopy.constants import SOS_TYPE_ATTR from linopy.remote import RemoteHandler from linopy.sos_reformulation import ( @@ -426,7 +426,7 @@ def test_to_netcdf_silent_after_undo(self, tmp_path: Path) -> None: m.to_netcdf(tmp_path / "m.nc") -@pytest.mark.skipif("highs" not in available_solvers, reason="HiGHS not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="HiGHS not installed") class TestSolverPathSOSCheck: """Solver._build() must raise on SOS-bearing model with non-SOS solver.""" @@ -443,7 +443,7 @@ def test_solver_from_name_raises_without_reformulation(self) -> None: solvers.Solver.from_name("highs", m, io_api="lp") -@pytest.mark.skipif("highs" not in available_solvers, reason="HiGHS not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="HiGHS not installed") class TestSolveAutoUndoOnFailure: """Model.solve must auto-undo SOS reformulation when build/solve raises.""" @@ -475,7 +475,7 @@ def boom(*args: object, **kwargs: object) -> None: m.solve(solver_name="highs", reformulate_sos=True) -@pytest.mark.skipif("highs" not in available_solvers, reason="HiGHS not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="HiGHS not installed") class TestSolveWithReformulation: """Tests for solving with SOS reformulation.""" @@ -485,13 +485,13 @@ class TestSolveWithReformulation: pytest.param( "gurobi", marks=pytest.mark.skipif( - "gurobi" not in available_solvers, reason="Gurobi not installed" + "gurobi" not in licensed_solvers, reason="Gurobi not installed" ), ), pytest.param( "highs", marks=pytest.mark.skipif( - "highs" not in available_solvers, reason="HiGHS not installed" + "highs" not in licensed_solvers, reason="HiGHS not installed" ), ), ], @@ -651,7 +651,7 @@ def test_multidimensional_sos2_with_highs(self) -> None: assert abs(nonzero_indices[1] - nonzero_indices[0]) == 1 -@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobi not installed") +@pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobi not installed") class TestEquivalenceWithGurobi: """Tests comparing reformulated solutions with native Gurobi SOS.""" @@ -677,7 +677,7 @@ def test_sos1_equivalence(self) -> None: m2.add_sos_constraints(x2, sos_type=1, sos_dim="i") m2.add_objective(x2 * np.array([1, 2, 3]), sense="max") - if "highs" in available_solvers: + if "highs" in licensed_solvers: m2.solve(solver_name="highs", reformulate_sos=True) assert m1.objective.value is not None assert m2.objective.value is not None @@ -705,7 +705,7 @@ def test_sos2_equivalence(self) -> None: m2.add_sos_constraints(x2, sos_type=2, sos_dim="i") m2.add_objective(x2 * np.array([1, 2, 3]), sense="max") - if "highs" in available_solvers: + if "highs" in licensed_solvers: m2.solve(solver_name="highs", reformulate_sos=True) assert m1.objective.value is not None assert m2.objective.value is not None @@ -788,7 +788,7 @@ def test_custom_big_m_removed_on_remove_sos(self) -> None: assert "big_m_upper" not in x.attrs -@pytest.mark.skipif("highs" not in available_solvers, reason="HiGHS not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="HiGHS not installed") class TestCustomBigM: """Tests for custom Big-M functionality.""" @@ -853,7 +853,7 @@ def test_solve_twice_with_reformulate_sos(self) -> None: assert np.isclose(obj1, obj2, atol=1e-5) -@pytest.mark.skipif("highs" not in available_solvers, reason="HiGHS not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="HiGHS not installed") class TestNoSosConstraints: def test_reformulate_sos_true_with_no_sos(self) -> None: m = Model() @@ -991,7 +991,7 @@ def test_undo_restores_skipped_zero_bounds(self) -> None: assert list(m.variables.sos) == ["x"] -@pytest.mark.skipif("highs" not in available_solvers, reason="HiGHS not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="HiGHS not installed") class TestUnsortedCoords: def test_sos2_unsorted_coords_matches_sorted(self) -> None: coeffs = np.array([1, 2, 3]) @@ -1032,7 +1032,7 @@ def test_sos1_unsorted_coords(self) -> None: assert np.isclose(m.objective.value, 3, atol=1e-5) -@pytest.mark.skipif("highs" not in available_solvers, reason="HiGHS not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="HiGHS not installed") class TestAutoReformulation: """Tests for reformulate_sos='auto' functionality.""" @@ -1084,9 +1084,7 @@ def test_auto_emits_info_no_warning( assert any("Reformulating SOS" in msg for msg in caplog.messages) assert not any("supports SOS natively" in msg for msg in caplog.messages) - @pytest.mark.skipif( - "gurobi" not in available_solvers, reason="Gurobi not installed" - ) + @pytest.mark.skipif("gurobi" not in licensed_solvers, reason="Gurobi not installed") def test_auto_passes_through_native_sos_without_reformulation(self) -> None: import gurobipy @@ -1172,7 +1170,7 @@ def test_auto_with_none_solver_raises(self) -> None: self._sos_model()._resolve_sos_reformulation(None, "auto") -@pytest.mark.skipif("highs" not in available_solvers, reason="HiGHS not installed") +@pytest.mark.skipif("highs" not in licensed_solvers, reason="HiGHS not installed") class TestRemoteBracket: """ Model.solve(remote=...) must bracket SOS reformulation around the remote diff --git a/test/test_sos_weights.py b/test/test_sos_weights.py index 879c9908b..d7fc52993 100644 --- a/test/test_sos_weights.py +++ b/test/test_sos_weights.py @@ -15,7 +15,7 @@ import pandas as pd import pytest -from linopy import Model, available_solvers +from linopy import Model, licensed_solvers from linopy.common import sos_weights #: Gains that separate the two orders: the first two members declared are worth @@ -24,7 +24,7 @@ GAINS = [1.0, 1.0, 0.1] needs_highs = pytest.mark.skipif( - "highs" not in available_solvers, reason="HiGHS not installed" + "highs" not in licensed_solvers, reason="HiGHS not installed" ) From fbc4a1d53cc239570de9207d11c75cca0b29e3f5 Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 28 Sep 2026 12:08:22 +0200 Subject: [PATCH 2/3] test(io): to_mosek builds a task without a licence, gate it on available_solvers --- test/test_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_io.py b/test/test_io.py index 29f208770..10801068f 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -615,7 +615,7 @@ def test_to_highspy(model: Model) -> None: assert h.getLp().num_col_ > 0 -@pytest.mark.skipif("mosek" not in licensed_solvers, reason="Mosek not installed") +@pytest.mark.skipif("mosek" not in available_solvers, reason="Mosek not installed") def test_to_mosek(model: Model) -> None: task = model.to_mosek() assert task.getnumvar() > 0 From 55422be9f1cd9767f6b70d4d24dd3db5a8fc1506 Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 28 Sep 2026 12:42:12 +0200 Subject: [PATCH 3/3] test(io): name a mosek task without a licence, gate its set_names cases on available_solvers --- test/test_io.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_io.py b/test/test_io.py index 10801068f..577d66646 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -675,7 +675,8 @@ def test_to_solver_set_names( set_names: bool | None, expected: bool, ) -> None: - if solver not in licensed_solvers: + usable = available_solvers if solver == "mosek" else licensed_solvers + if solver not in usable: pytest.skip(f"{solver} not installed") to_solver, names = SOLVER_IO[solver] named = to_solver(model, set_names=True)