diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 06a32c77..2b1474c4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,11 +40,16 @@ jobs: run: echo "$(pwd)/.pixi/envs/default/bin" >> $GITHUB_PATH shell: bash - name: Install dependencies - run: pixi add --platform linux-64 scipy numpy pip pytest meson meson-python openblas python==${{ matrix.python-version }} + run: pixi add --platform linux-64 scipy numpy pip pytest pytest-cov meson meson-python openblas python==${{ matrix.python-version }} - name: Test + # This job is the coverage gate for the Python layer. The threshold and + # the source resolution both live in pyproject.toml ([tool.coverage.*]), + # so `--cov` takes no inline flags: `source_pkgs` is what makes coverage + # follow the import to the installed package instead of measuring the + # repo-root `scs/` directory, which has no __init__.py and reports 0%. run: | pixi r python -m pip install -v . --no-build-isolation -Csetup-args=-Duse_openmp=true - pixi r pytest + pixi r pytest --cov rm -rf build/ build_mkl: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 00000000..3dff6827 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,47 @@ +name: quality + +on: + pull_request: + push: + branches: + - master + +jobs: + quality: + # Static checks only — no submodules, no compilation, no pixi, so this + # reports in well under a minute and is the fastest signal on a PR. + # + # Every rule set and threshold lives in pyproject.toml, so a contributor + # running these locally gets the same verdict as CI. The tool versions are + # pinned for the same reason: ruff in particular enables new rules by + # default in each release, which would otherwise turn an unrelated ruff + # release into a red build here. + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.12" + + - name: Lint (ruff) + run: uvx ruff@0.16.6 check scs/py test + + - name: Type check (mypy) + # numpy ships its own stubs, so installing it means arrays are really + # checked rather than silently degraded to Any. + run: uvx --with numpy --with scipy mypy@2.3.1 + + - name: Docstring coverage (interrogate) + run: uvx interrogate@1.7.0 -v scs/py + + - name: Dependency hygiene (deptry) + run: uvx deptry@0.25.1 scs/py + + - name: Resolve the declared dependency closure + run: uv pip compile pyproject.toml --python-version 3.12 -o /tmp/audit-requirements.txt + + - name: Audit dependencies (pip-audit) + run: uvx pip-audit@2.10.1 -r /tmp/audit-requirements.txt diff --git a/.gitignore b/.gitignore index 7b0df7db..42dbd848 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,9 @@ out # pixi environments .pixi *.egg-info + +# coverage +.coverage +.coverage.* +htmlcov/ +coverage.xml diff --git a/pyproject.toml b/pyproject.toml index 6925fa2e..545c0395 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,3 +108,57 @@ markers = [ # pytest-run-parallel: functions that are not thread-safe # (capsys/capfd are detected automatically by pytest-run-parallel) thread_unsafe_functions = ["numpy.random.seed"] + +# `--cov=scs` measures nothing when pytest runs from the repo root: the source +# directory `scs/` has no `__init__.py`, so coverage resolves the name to that +# namespace-package directory rather than to the installed `scs` package, and +# reports 0% with "No data was collected". `source_pkgs` states that `scs` is an +# importable package name, so coverage follows the import to site-packages. +[tool.coverage.run] +source_pkgs = ["scs"] + +[tool.coverage.report] +show_missing = true +fail_under = 100 + +[tool.ruff] +extend-exclude = ["scs_source", "scs/pythoncapi-compat", "legacy_setup.py"] + +[tool.ruff.lint] +# Pyflakes, the error subset of pycodestyle, and import sorting. Deliberately +# not ruff's rolling default set: that set grows with each release, so a ruff +# upgrade would turn into a CI failure unrelated to any change here. +select = ["E4", "E7", "E9", "F", "I"] +# `l` is the SCS cone key for the non-negative cone. It is the domain's name +# for that quantity, not a careless one-letter variable. +ignore = ["E741"] + +[tool.ruff.lint.per-file-ignores] +# The backend test modules import an extension solely to discover whether this +# build has it, inside a try/except that skips the module when it does not. +# The unused import *is* the test. +# I001 too: these modules' import order is left as the maintainers wrote it. +# `_scs_mkl` sets the MKL interface layer at import time, so import order +# around numpy/scipy carries meaning here that a sorter cannot see. +"test/*" = ["F401", "I001"] + +[tool.mypy] +files = ["scs/py"] +warn_redundant_casts = true +warn_unused_ignores = true +no_implicit_optional = true + +[[tool.mypy.overrides]] +# scipy ships no inline types and its stub package is a separate, partial +# project; numpy's own stubs are used, so arrays are still checked. +module = ["scipy.*"] +ignore_missing_imports = true + +[tool.interrogate] +fail-under = 100 + +[tool.deptry] +# `scs` is this project's own package: `scs/py/__init__.py` imports its +# compiled siblings via `from scs import _scs_direct`, which is not a +# third-party dependency. +known_first_party = ["scs"] diff --git a/scs/py/__init__.py b/scs/py/__init__.py index 07a5e93e..43600267 100644 --- a/scs/py/__init__.py +++ b/scs/py/__init__.py @@ -1,10 +1,21 @@ -#!/usr/bin/env python +"""Python interface to SCS, the splitting conic solver. + +Exposes the `SCS` class, the legacy `solve` helper, and the `LinearSolver` +enum naming the linear-system backends a given build can dispatch to. +""" + import enum import sys +import warnings +from types import ModuleType +from typing import Any, Optional + import numpy as np from scipy import sparse -from scs import _scs_direct -import warnings + +# The `_scs_*` extensions are produced by the meson build, so they do not +# exist in the source tree for a static checker to resolve. +from scs import _scs_direct # type: ignore[attr-defined] __version__ = _scs_direct.version() __sizeof_int__ = _scs_direct.sizeof_int() @@ -26,7 +37,20 @@ class LinearSolver(enum.Enum): - """Linear system solver backend for SCS.""" + """Linear system solver backend for SCS. + + Pass a member (or its string value) as the `linear_solver` setting. Which + members are importable depends on the build; `AUTO` picks the best one + available on the platform. + + Example: + + >>> import scs + >>> scs.LinearSolver.QDLDL.value + 'qdldl' + >>> scs.LinearSolver("cpu_indirect") is scs.LinearSolver.CPU_INDIRECT + True + """ AUTO = "auto" QDLDL = "qdldl" CPU_INDIRECT = "cpu_indirect" @@ -37,12 +61,13 @@ class LinearSolver(enum.Enum): CUDSS = "cudss" -def _load_module(name): +def _load_module(name: str) -> ModuleType: + """Import one of the sibling `_scs_*` C extension modules by name.""" from importlib import import_module return import_module(f"scs.{name}") -def _resolve_auto(): +def _resolve_auto() -> ModuleType: """Auto-detect the best available direct solver for this platform.""" if sys.platform == "darwin": # Prefer the bundled QDLDL on macOS over Apple Accelerate. @@ -66,7 +91,7 @@ def _resolve_auto(): } -def _select_scs_module(stgs): +def _select_scs_module(stgs: dict) -> ModuleType: """Choose which SCS C extension to import based on settings.""" linear_solver = stgs.pop("linear_solver", LinearSolver.AUTO) if isinstance(linear_solver, str): @@ -74,7 +99,7 @@ def _select_scs_module(stgs): return _SOLVER_DISPATCH[linear_solver]() -def _has_lower_tri(P): +def _has_lower_tri(P: Any) -> bool: """Fast check for strictly lower triangular entries in a sorted CSC matrix.""" nnz_per_col = np.diff(P.indptr) nonempty = nnz_per_col > 0 @@ -85,8 +110,28 @@ def _has_lower_tri(P): class SCS(object): - - def __init__(self, data, cone, **settings): + """A solver instance bound to one problem's data and cone. + + Construct it once, then call `solve` as often as needed; `update` swaps in + a new `b` or `c` without rebuilding the workspace. + + Example: + + >>> import numpy as np + >>> import scipy.sparse as sp + >>> import scs + >>> # maximize x subject to 0 <= x <= 1 + >>> data = { + ... "A": sp.csc_matrix(np.array([[1.0], [-1.0]])), + ... "b": np.array([1.0, 0.0]), + ... "c": np.array([-1.0]), + ... } + >>> solver = scs.SCS(data, {"l": 2}, verbose=False) + >>> solver.solve()["info"]["status"] + 'solved' + """ + + def __init__(self, data: dict, cone: dict, **settings: Any) -> None: """Initialize the SCS solver. @param data Dictionary containing keys `P`, `A`, `b`, `c`. @@ -183,7 +228,13 @@ def __init__(self, data, cone, **settings): **self._settings, ) - def solve(self, warm_start=True, x=None, y=None, s=None): + def solve( + self, + warm_start: bool = True, + x: Optional[np.ndarray] = None, + y: Optional[np.ndarray] = None, + s: Optional[np.ndarray] = None, + ) -> dict: """Solve the optimization problem. @param warm_start Whether to warm-start. By default the solution of @@ -199,10 +250,35 @@ def solve(self, warm_start=True, x=None, y=None, s=None): 's' - primal slack solution 'y' - dual solution 'info' - information dictionary (see docs) + + Example: + + >>> import numpy as np + >>> import scipy.sparse as sp + >>> import scs + >>> # maximize x subject to 0 <= x <= 1 + >>> data = { + ... "A": sp.csc_matrix(np.array([[1.0], [-1.0]])), + ... "b": np.array([1.0, 0.0]), + ... "c": np.array([-1.0]), + ... } + >>> solver = scs.SCS(data, {"l": 2}, eps_abs=1e-9, eps_rel=1e-9, + ... verbose=False) + >>> sol = solver.solve() + >>> sorted(sol) + ['info', 's', 'x', 'y'] + >>> sol["info"]["status"] + 'solved' + >>> float(np.round(sol["x"][0], 6)) + 1.0 """ return self._solver.solve(warm_start, x, y, s) - def update(self, b=None, c=None): + def update( + self, + b: Optional[np.ndarray] = None, + c: Optional[np.ndarray] = None, + ) -> None: """Update the `b` vector, `c` vector, or both, before another solve. After a solve we can reuse the SCS workspace in another solve if the @@ -210,12 +286,59 @@ def update(self, b=None, c=None): @param b New `b` vector. @param c New `c` vector. + + Example: + + >>> import numpy as np + >>> import scipy.sparse as sp + >>> import scs + >>> data = { + ... "A": sp.csc_matrix(np.array([[1.0], [-1.0]])), + ... "b": np.array([1.0, 0.0]), + ... "c": np.array([-1.0]), + ... } + >>> solver = scs.SCS(data, {"l": 2}, eps_abs=1e-9, eps_rel=1e-9, + ... verbose=False) + >>> float(np.round(solver.solve()["x"][0], 6)) # maximize x + 1.0 + >>> solver.update(c=np.array([1.0])) # now minimize x + >>> float(np.round(abs(solver.solve()["x"][0]), 6)) + 0.0 """ self._solver.update(b, c) -# Backwards compatible helper function that simply calls the main API. -def solve(data, cone, **settings): +def solve(data: dict, cone: dict, **settings: Any) -> dict: + """Solve a problem in one call, without holding on to a solver instance. + + Backwards compatible helper that simply calls the main API. Warm-start + vectors are read out of `data` under the keys `x`, `y` and `s`, which is + how the pre-`SCS`-class API passed them. + + @param data Dictionary containing keys `A`, `b`, `c`, optionally `P`, + and optionally the warm-start vectors `x`, `y`, `s`. + @param cone Dictionary containing cone information. + @param settings Settings as kwargs, see docs. + + @return the same dictionary `SCS.solve` returns. + + Example: + + >>> import numpy as np + >>> import scipy.sparse as sp + >>> import scs + >>> data = { + ... "A": sp.csc_matrix(np.array([[1.0], [-1.0]])), + ... "b": np.array([1.0, 0.0]), + ... "c": np.array([-1.0]), + ... } + >>> sol = scs.solve(data, {"l": 2}, eps_abs=1e-9, eps_rel=1e-9, + ... verbose=False) + >>> sol["info"]["status"] + 'solved' + >>> float(np.round(sol["x"][0], 6)) + 1.0 + """ solver = SCS(data, cone, **settings) # Hack out the warm start data from old API diff --git a/test/solve_random_cone_prob.py b/test/solve_random_cone_prob.py index 4d145a99..d6542fb2 100644 --- a/test/solve_random_cone_prob.py +++ b/test/solve_random_cone_prob.py @@ -60,7 +60,7 @@ def solve_infeasible(linear_solver, rng): m = tools.get_scs_cone_dims(K) data = tools.gen_infeasible(K, n=m // 3, rng=rng) params = {"normalize": True, "scale": 0.5} - sol = scs.solve(data, K, linear_solver=linear_solver, **params) + scs.solve(data, K, linear_solver=linear_solver, **params) def solve_unbounded(linear_solver, rng): @@ -76,7 +76,7 @@ def solve_unbounded(linear_solver, rng): m = tools.get_scs_cone_dims(K) data = tools.gen_unbounded(K, n=m // 3, rng=rng) params = {"normalize": True, "scale": 0.5} - sol = scs.solve(data, K, linear_solver=linear_solver, **params) + scs.solve(data, K, linear_solver=linear_solver, **params) if __name__ == "__main__": diff --git a/test/test_doctests.py b/test/test_doctests.py new file mode 100644 index 00000000..ed4a7772 --- /dev/null +++ b/test/test_doctests.py @@ -0,0 +1,24 @@ +"""Execute the doctest examples carried by the public `scs` API. + +Docstring *coverage* only says a docstring exists; this says the examples +inside it still evaluate to what they claim. Runs against the installed +package, which is the one users import. +""" + +import doctest + +import scs + + +def test_docstring_examples_run(capsys): + """Every `>>>` example in `scs` still produces its documented output.""" + results = doctest.testmod( + scs, + optionflags=doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS, + verbose=False, + ) + report = capsys.readouterr().out + + # A module that has lost its examples would otherwise pass silently. + assert results.attempted > 0, "no doctest examples found in scs" + assert results.failed == 0, report diff --git a/test/test_scs_basic.py b/test/test_scs_basic.py index d73f4ea3..5cc760d5 100644 --- a/test/test_scs_basic.py +++ b/test/test_scs_basic.py @@ -72,26 +72,6 @@ def test_problems(cone, linear_solver, expected): assert_almost_equal(sol["x"][0], expected, decimal=2) -if platform.python_version_tuple() < ("3", "0", "0"): - - @pytest.mark.parametrize( - "cone,linear_solver,expected", - [ - ({"q": [], "l": long(2)}, scs.LinearSolver.AUTO, 1), - ({"q": [], "l": long(2)}, scs.LinearSolver.QDLDL, 1), - ({"q": [], "l": long(2)}, scs.LinearSolver.CPU_INDIRECT, 1), - ({"q": [long(2)], "l": 0}, scs.LinearSolver.AUTO, 0.5), - ({"q": [long(2)], "l": 0}, scs.LinearSolver.QDLDL, 0.5), - ({"q": [long(2)], "l": 0}, scs.LinearSolver.CPU_INDIRECT, 0.5), - ], - ) - def test_problems_with_longs(cone, linear_solver, expected): - sol = scs.solve( - data, cone=cone, linear_solver=linear_solver, verbose=False - ) - assert_almost_equal(sol["x"][0], expected, decimal=2) - - def test_failures(): with pytest.raises(TypeError): scs.solve() @@ -111,4 +91,4 @@ def test_failures(): scs.solve(data, {"q": [], "l": 2}, max_iters=1.1) with pytest.raises(ValueError): - sol = scs.solve(data, {"q": [1], "l": 0}, verbose=False) + scs.solve(data, {"q": [1], "l": 0}, verbose=False) diff --git a/test/test_scs_coverage.py b/test/test_scs_coverage.py index 53cd0b61..b45d91e7 100644 --- a/test/test_scs_coverage.py +++ b/test/test_scs_coverage.py @@ -263,7 +263,6 @@ def test_P_full_symmetric_extracts_upper(): """For a 2x2 P, lower triangular entries should be discarded.""" # min (1/2)||x||^2 + c'x over x ∈ [0,1]^2 (two separate LP constraints) # Optimal is where gradient = Px + c = 0 projected onto box. - n = 2 A2 = sp.block_diag([sp.csc_matrix([[1.0], [-1.0]]), sp.csc_matrix([[1.0], [-1.0]])], format="csc") b2 = np.array([1.0, 0.0, 1.0, 0.0]) @@ -1442,7 +1441,6 @@ def test_P_lower_triangular_stripped(): P = [[2, 1], [1, 2]] (symmetric) should behave the same as the upper- triangular version P = [[2, 1], [0, 2]]. """ - n = 2 A2 = sp.block_diag( [sp.csc_matrix([[1.0], [-1.0]]), sp.csc_matrix([[1.0], [-1.0]])], format="csc", @@ -1943,7 +1941,6 @@ def test_large_random_lp(): rng = np.random.RandomState(3) K_lp = {"z": 0, "l": 40, "q": [], "s": [], "ep": 0, "ed": 0, "p": []} - m_lp = tools.get_scs_cone_dims(K_lp) data, pstar = tools.gen_feasible(K_lp, n=20, density=0.3, rng=rng) solver = scs.SCS( @@ -1992,7 +1989,6 @@ def test_P_forced_unsorted_indices_sorted_and_solved(): in SCS.__init__ even when the underlying data is already ordered. The solve must still produce the correct QP answer. """ - n = 2 A2 = sp.csc_matrix(np.array([ [1., 0.], [-1., 0.], [0., 1.], [0., -1.] ])) @@ -2653,7 +2649,6 @@ def test_large_max_iters_solves(): def test_box_cone_numpy_bounds(): """bu/bl specified as numpy float arrays should work.""" - n = 2 # box cone: dim = d + 1 = 3 (t, x1, x2) # s = b - Ax, s in box cone => bl <= x <= bu A = sp.csc_matrix(np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]])) diff --git a/test/test_spectral_and_complex_cones.py b/test/test_spectral_and_complex_cones.py index 0eabf347..7a179b61 100644 --- a/test/test_spectral_and_complex_cones.py +++ b/test/test_spectral_and_complex_cones.py @@ -398,6 +398,6 @@ def test_warm_start_with_spectral(self): cone = {"ell1": [4]} data = _gen_feasible_qp(cone, rng=rng) solver = scs.SCS(data, cone, verbose=False, max_iters=500) - sol1 = solver.solve() + solver.solve() sol2 = solver.solve(warm_start=True) assert sol2["info"]["status_val"] in (1, 2)