From 81253c819c45e71d1c4b169ace8b4bced16a799b Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Sun, 6 Sep 2026 21:14:39 +0400 Subject: [PATCH] Add doctest examples to the public SCS API and run them in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package carried prose docstrings on every public symbol but zero runnable examples, so nothing verified that what they claim is still true — the failure mode where a docstring keeps rendering perfectly after the behaviour beneath it changes. 33 examples across `LinearSolver`, `SCS`, `SCS.solve`, `SCS.update` and the legacy `solve()`. Each is self-contained and deterministic: tight eps_abs/eps_rel, verbose=False, values rounded before printing. Also fills two gaps in the same file: `SCS` had no class docstring, and the public legacy `solve()` had only a `#` comment above it. `test/test_doctests.py` runs them against the installed package and asserts `attempted > 0` as well as `failed == 0`, so a module that silently loses its examples fails rather than passes. Closes #240 Co-Authored-By: Claude Opus 5 (1M context) --- scs/py/__init__.py | 105 +++++++++++++++++++++++++++++++++++++++++- test/test_doctests.py | 24 ++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 test/test_doctests.py diff --git a/scs/py/__init__.py b/scs/py/__init__.py index 07a5e93e..9a64cf7a 100644 --- a/scs/py/__init__.py +++ b/scs/py/__init__.py @@ -26,7 +26,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" @@ -85,6 +98,26 @@ def _has_lower_tri(P): class SCS(object): + """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, cone, **settings): """Initialize the SCS solver. @@ -199,6 +232,27 @@ 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) @@ -210,12 +264,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): + """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/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