From 015508aac25ef32cab224eafc7d076e1b0af779d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:28:12 +0200 Subject: [PATCH] docs(architecture): show the public surface, and pin it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard rule 5 says the public interface is a declared model rather than a Python API. Nothing said what that was worth in names, and nothing stopped it growing. - PUBLIC_API in tests/test_architecture.py pins the surface by role, checked in both directions: __all__ must match the table, and no public non-module attribute may exist outside it. The second half caught PackageNotFoundError, an stdlib exception sitting in lpspec's namespace since the version shim was written — now imported privately, like the version helper beside it. - A second test holds the linopy shim to its two verbs; a third would mean it had started being a lane of its own. - ARCHITECTURE.md gains "The Python surface": sixteen names in four groups, plus the shim and the Result methods. Hard rule 5 links to it. - The consumers diagram carries the names, one per box, so solid-vs-dashed reads as "has a name today" rather than needing the caption to say so. Co-Authored-By: Claude Opus 5 (1M context) --- docs/ARCHITECTURE.md | 56 +++++++++++++++++++++------ src/lpspec/__init__.py | 4 +- tests/test_architecture.py | 77 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 13 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 07cc2ef4a..54a11c6c8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -112,7 +112,7 @@ flowchart TB Y --> AST - AST["the whole model, typed and checked
before a byte of data is read"] + AST["the whole model, typed and checked
before a byte of data is read
lps.load_schema → MathSchema"] AST --> SHOW AST --> CHECK @@ -120,30 +120,30 @@ flowchart TB subgraph SHOW["show it — no data, no solver"] direction TB - S1(["typeset the math for a paper or a review"]) - S2(["drive it from the command line"]) + S1(["typeset the math for a paper or a review
lps.to_latex · to_typst · to_markdown
how names print: SymbolTable"]) + S2(["drive it from the command line
python -m lpspec <format>"]) S3(["watch what a build is doing"]) end subgraph CHECK["check it — no data, no solver"] direction TB - C1(["will this build, and is the math sayable?"]) - C2(["do the dimensions line up?"]) + C1(["will this build, and is the math sayable?
lps.check"]) + C2(["do the dimensions line up?
lps.check — same pass, same answer"]) C3(["will that solver take it, and how big is it?"]) end subgraph RUN["run it — the only part that touches your data"] direction TB - R1(["stream it straight into a solver"]) - R2(["write an LP file for anything else"]) - R3(["put the same math on a linopy model"]) + R1(["stream it straight into a solver
lps.solve · lps.build for several sinks"]) + R2(["write an LP file for anything else
lps.write"]) + R3(["put the same math on a linopy model
lpspec.linopy.build · .extend"]) end R1 --> ANS subgraph ANS["your answers, as tables you can join"] direction TB - A1(["values and shadow prices, by name"]) + A1(["values and shadow prices, by name
result.objective · .primal · .dual
bridges out: .to_pandas · .to_parquet"]) A2(["derived results and diagnostics"]) A3(["change a number, re-solve, keep the labels"]) end @@ -158,7 +158,10 @@ flowchart TB class RUN,CHECK,SHOW,ANS fam ``` -**Solid is what ships today; dashed is what the shape makes cheap.** None of the +**Solid is what ships today; dashed is what the shape makes cheap** — which is +also to say the solid boxes are the ones with a name in them. That is the whole +Python surface bar the error hierarchy, which is cross-cutting rather than a +box; [the table below](#the-python-surface) is the same set, listed. None of the dashed boxes is a rewrite — each reads the same AST the engine reads, so a renderer is a tree walk, a check is a pass with no data bound, and a new output format is one function in `relational/sinks/`. `typeset/` is that claim cashed: @@ -188,6 +191,34 @@ ceiling in [docs/design/ceiling.md](design/ceiling.md) protects — a new consum is free, a new primitive is taxed. What is planned, and why, is [docs/ROADMAP.md](ROADMAP.md). +### The Python surface + +**Sixteen names, and that is the feature.** The model is the YAML file; Python +is how you *run* it, so the surface is the diagram above written out — one +group per box, nothing that constructs math, nothing that reaches the plan. + +| | `lpspec.` | | +|---|---|---| +| **run it** | `solve` · `build` · `write` · `check` | the four verbs; `check` binds no data | +| **load it** | `load_schema` · `MathSchema` | parse and validate; the schema is the contract under the YAML | +| **show it** | `to_latex` · `to_typst` · `to_markdown` · `SymbolTable` | one per format, plus how names print | +| **catch it** | `LinopyYamlError` · `LanguageError` · `DataError` · `DimensionError` · `SchemaError` · `PiecewiseExpansionError` | one hierarchy, split model from run | + +Plus two on the opt-in shim — `lpspec.linopy.build` and `.extend` — and the +methods of what `solve` hands back (`Result`: `objective`, `primal`, `dual`, +`to_pandas` / `to_dataarray` / `to_parquet`, the status pair, `close`). + +`tests/test_architecture.py` pins all of it: `__all__` must match the table, +**and** no public non-module attribute may exist outside it. Both directions, +because either alone rots — the first catches a name documented and never +exported, the second a helper that leaked into the namespace by being imported +at the top of `__init__.py`. That check found one the day it was written. + +There is deliberately no Python API for *constructing* a model, no way to hand +in a plan, and no registry to populate. That is hard rule 5 below, and it is +what makes a `.yaml` file the thing you review, diff and cite — rather than the +serialisation of a Python object you would have to run to understand. + ## Hard rules *Enforced, not aspirational: `tests/test_architecture.py` encodes these as @@ -247,7 +278,10 @@ that made an implementation choice load-bearing in the language's rulebook. whether that seam is ever blessed is open (see [Composition](design/ceiling.md#composition-component-libraries)). The Python surface is the runner (`api.py`); the plan is internal, and a stable - plan-construction API is a later possibility, not a current contract. + plan-construction API is a later possibility, not a current contract. The + whole of it is [sixteen names](#the-python-surface), pinned by a test — so + the surface grows through a list a reviewer reads, the way every other fence + here does. ## The relational lane diff --git a/src/lpspec/__init__.py b/src/lpspec/__init__.py index 7a67ec52b..dd16389cb 100644 --- a/src/lpspec/__init__.py +++ b/src/lpspec/__init__.py @@ -15,7 +15,7 @@ result.to_dataarray('p') # labelled, for array post-processing """ -from importlib.metadata import PackageNotFoundError +from importlib.metadata import PackageNotFoundError as _PackageNotFoundError from importlib.metadata import version as _installed_version from lpspec.api import build, check, load_schema, solve, write @@ -52,5 +52,5 @@ try: # the git tag is the source of truth; hatch-vcs bakes it into the metadata __version__ = _installed_version('lpspec') -except PackageNotFoundError: # running from a source tree with nothing installed +except _PackageNotFoundError: # running from a source tree with nothing installed __version__ = '0.0.0' diff --git a/tests/test_architecture.py b/tests/test_architecture.py index 67b557a64..0e2911d6f 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -281,6 +281,83 @@ def test_typesets_import_closure_needs_no_third_party_engine(): ) +#: The whole Python surface, by role. Hard rule 5 says the public interface is +#: a declared model rather than a Python API — this is what "rather than" is +#: worth in names. Adding one is a row here, which is a line in a diff a +#: reviewer reads; the fences elsewhere in this file work the same way. +PUBLIC_API = { + 'run it': {'build', 'check', 'solve', 'write'}, + 'load it': {'load_schema', 'MathSchema'}, + 'show it': {'to_latex', 'to_markdown', 'to_typst', 'SymbolTable'}, + 'catch it': { + 'LinopyYamlError', + 'LanguageError', + 'DataError', + 'DimensionError', + 'SchemaError', + 'PiecewiseExpansionError', + }, +} + +#: The opt-in shim, which is a surface of its own — and deliberately two verbs. +PUBLIC_API_LINOPY = {'build', 'extend'} + + +def test_the_public_surface_is_exactly_what_is_declared(): + """Hard rule 5, in names: the Python surface is narrow, and stays narrow. + + Narrow is a feature, not an accident — it is the half of "the public + interface is a declared model" that a reader can count. A model travels as + YAML; Python is how you *run* it, so the runner has four verbs, the + language one loader, the readers one each, and the errors one hierarchy. + + Two directions, because either alone rots. ``__all__`` must match the + table (a name added quietly, or documented and never exported), and no + public non-module attribute may exist outside it (a helper that leaked + into the namespace by being imported at the top of ``__init__``). + """ + import inspect + + import lpspec + + declared = {name for names in PUBLIC_API.values() for name in names} + assert set(lpspec.__all__) == declared, ( + f'lpspec.__all__ and PUBLIC_API disagree: only in __all__ ' + f'{sorted(set(lpspec.__all__) - declared)}, only in the table ' + f'{sorted(declared - set(lpspec.__all__))} — add the name to PUBLIC_API ' + f'with the role it plays, and to docs/ARCHITECTURE.md' + ) + + leaked = sorted( + name + for name in dir(lpspec) + if not name.startswith('_') + and name not in declared + and not inspect.ismodule(getattr(lpspec, name)) # submodules are import paths, not API + ) + assert not leaked, ( + f'public names outside __all__: {leaked} — a surface that grows by ' + f'accident is not narrow. Import it privately, or declare it.' + ) + + +def test_the_linopy_shim_stays_two_verbs(): + """The shim attaches YAML math to a model that already exists. + + That is the whole of it — ``build`` makes one, ``extend`` adds to one. A + third verb would mean the shim had started being a lane of its own, which + hard rule 3 spends its length refusing. Read statically: the module + imports linopy, and this must run on a bare install. + """ + tree = ast.parse((PKG / 'linopy' / '__init__.py').read_text()) + declared = next( + ast.literal_eval(node.value) + for node in tree.body + if isinstance(node, ast.Assign) and any(ast.unparse(t) == '__all__' for t in node.targets) + ) + assert set(declared) == PUBLIC_API_LINOPY, f'the linopy shim exports {sorted(declared)}' + + def test_expansion_has_no_mutable_module_state(): """Hard rule 5: YAML files are self-contained — nothing importable may accumulate state that changes what a file means."""