Skip to content

✨ sphinx-needs: read variant data from [variants] through ub-project, keeping [needs] variant_data* - #1996

Merged
chrisjsewell merged 11 commits into
masterfrom
feat/needs-variants-table
Sep 30, 2026
Merged

chrisjsewell merged 11 commits into
masterfrom
feat/needs-variants-table

Conversation

@chrisjsewell

Copy link
Copy Markdown
Member

sphinx-needs becomes a consumer of ub-project (≥ 1.1.0) for the variant data in the needs_from_toml file. A project may now
declare its variant data in a top-level [variants] table — or [<prefix>.variants] under needs_from_toml_table — and the legacy
[needs] variant_data / variant_data_file keys keep working, reported only as a -v line.

What changes (each pinned by a test in tests/test_variants_table.py)

  1. [variants] data / data_file (and [<prefix>.variants]) are read.
  2. Both locations declared → [variants] wins whole; one needs.variant_data_location warning per ignored [needs] key.
  3. Data read from [needs] → one -v line per key; nothing without -v; -W stays green (ubCode's quickstart variants template,
    copied verbatim, is the test).
  4. An unknown key in [variants] → a needs.variants_unknown_key warning, silenced by exactly that subtype.
  5. variants = <not a table> → NeedsConfigException (was silently ignored).
  6. A file with [variants] and no [needs] table is read (it used to warn 'needs' and read nothing) — even an empty
    [variants] (or variants = {}), which declares nothing, turns that warning (rc 1 under -W) into silence.
  7. Bad TOML-declared variant data — the inline table or the file — is refused while the TOML is read, in ub-project's words
    (<toml>: [needs] variant_data must be a table, got str, <toml>: [needs]: variant data file not found: <p>); the same
    NeedsConfigException, except a directory or non-UTF-8 data file, which used to escape as a raw ExtensionError. The
    conf.py route is untouched.
  8. The bad-TOML warning's tail is ub-project's text; a non-UTF-8 file and a directory get a clear message.
  9. variant_data_file = "" or a list in the TOML → NeedsConfigException naming the key (was an ExtensionError traceback).

Also: an empty segment in needs_from_toml_table ([""]) is now a configuration error (NeedsConfigException), where a
["".needs] table used to be read. A TOML that cannot be parsed at all still only warns, whatever the parser raised (pinned with a deeply nested array and an
integer past Python's conversion limit — two exception classes, so the catch cannot be narrowed to one).

What stays byte-identical, and how that is shown

Every existing test passes unmodified (1805 → 1847 = 1805 + 42 new, the same 11 skips). -D overrides one key: an overridden key is
removed from both locations before the read, so it neither opens a file nor decides the location. conf.py values still lose to
the location read per key and fill the gaps it leaves — the reader writes the declared keys of the location read to the two confvals one by
one, and resolve_variant_data_config merges them exactly as before (the data file is therefore read twice). The data-file confval
is still written through _abs_path, so its string (resolved, symlinks followed) is unchanged. Every existing warning text and
subtype is unchanged. Seven mutations of the reader (merged map written, -D strip dropped, prefix ignored, missing-[needs]
continuation dropped, joined path written, both-set warning on needs.config, legacy line as a warning) each turn a named test red,
and so do eight more for the edges (the [needs] loop reading the ignored keys, the continuation checking the wrong table or
swallowing any error, the parse narrowed, the empty-segment ValueError escaping, the cause dropped, an unknown code warning,
an empty [variants] not counting).

The dependency

ub-project>=1.1.0,<2, a runtime dependency (stdlib-only). The lock carries the two-line edge only; uv lock --check passes on
uv 0.12.15 and 0.12.9. import-check-needs resolves ub-project==1.1.0 from PyPI.

The prefix

With needs_from_toml_table = ["tool"] the tables are [tool.needs] and [tool.variants], because a pyproject.toml reserves its
top level; a top-level [variants] is then someone else's table and is neither read nor reported — the contract's rule
(reading-contract.md §6.2), and the one ubCode (which has no prefix) agrees with.

The -v line and what follows

The legacy location is only a verbose line in this release, so projects created by ubCode's quickstart keep building under -W. A
later release adds a needs.variant_data_legacy_location warning, after ubCode's reader release; ubCode and sphinx-mounts do not
read [variants] yet, and the docs say a project built by either keeps [needs] until they do.

Docs

A [variants] subsection under needs_from_toml in configuration.rst, cross-linked from needs_variant_data and
needs_variant_data_file; the two new subtypes appear in the generated warnings list. The needs_from_toml caution that said
relative TOML paths resolve against the conf.py directory is corrected: they resolve against the TOML's own directory (and did).
The changelog entry is written at release time.

Review

Two adversarial reviewers, one fix round and a validation round; the refuter's differential ran the recon's 122 constructions through
master's reader and this one side by side. Two things found on the way are filed rather than fixed here: #1991 (a dotted
-D needs_variant_data.key=… is lost under a TOML inline table — pre-existing, and inherited by [variants] identically) and #1995
(ub-project lets a bare ValueError through for an oversized integer in a data file; this reader reports it as a configuration error
until ub-project names the file itself).

The runtime edge for reading the [variants] table of needs_from_toml.
The lock carries the minimal two-line edge only; a plain `uv lock`
re-derives fork markers without moving any version.
…s_unknown_key warning subtypes

Named after ub-project's diagnostic codes, so that ubCode and sphinx-needs
report the same findings in one vocabulary. Each is suppressible on its
own, rather than only together with every other needs.config check.
load_config_from_toml now reads the variant data of the needs_from_toml
file with ub_project.read_variants: from [variants] (or
[<prefix>.variants] under needs_from_toml_table), or from its legacy
location, [needs] variant_data / variant_data_file.

- both locations declared: [variants] wins whole, and each ignored
  [needs] key is a needs.variant_data_location warning;
- data read from [needs]: one line per key with -v, nothing otherwise;
- an unknown key in [variants]: a needs.variants_unknown_key warning;
- a file with [variants] and no [needs] table is read;
- a refusal (variants not a table, an empty or non-string data_file, a
  missing or invalid TOML-declared data file) is a NeedsConfigException.

What stays as it was: a -D override replaces one key and removes it
from both locations before the read, conf.py values still lose to the
TOML per key and fill the gaps it leaves, and the data file confval is
still written through _abs_path, so its string is unchanged. The merge
with conf.py values stays in resolve_variant_data_config.

The quickstart test project is a verbatim copy of ubCode's
rust/ubc_quickstart/src/templates/variants/ (ubcode 8732fbc065).
A subsection under needs_from_toml: the two keys, the legacy [needs]
location and its -v line, whole-location precedence and the
needs.variant_data_location warning, the per-key conf.py / -D rules,
the prefix rule that makes pyproject.toml work, the refusals, and that
ubCode and sphinx-mounts do not read [variants] yet. Cross-links from
needs_variant_data and needs_variant_data_file.

Also corrects the needs_from_toml caution, which said relative paths
in the toml are resolved against the conf.py directory: they are, and
were, resolved against the toml file's own directory.

The package AGENTS.md says what is now ub-project's and what stays in
load_config_from_toml.
Its data_file names a file that does not exist, so a reader that
decided the location from the top-level table would fail the build.
…ain, whatever the error

The parse was narrowed to catching ProjectConfigError, but load_toml
does not wrap every failure: a deeply nested array raises
RecursionError, which master turned into the needs.config "Error
loading" warning and the branch let escape as an ExtensionError.
Catch Exception around the parse, as before.

An empty needs_from_toml_table entry is refused by ub_project with a
ValueError; re-raise it as a NeedsConfigException, like its other
refusals, instead of a traceback.
…unpinned

- both locations with disjoint keys: nothing is read from [needs];
- [tool.variants] alone under needs_from_toml_table is read, and a
  top-level [variants] does not stand in for a missing [tool.needs];
- even an empty [variants] (or variants = {}) with no [needs] is silent;
- a broken [needs] (needs = 1, schema = 1) beside [variants] still ends
  the read with today's warning: only a missing [needs] goes on;
- a deeply nested TOML only warns;
- an empty needs_from_toml_table entry is a NeedsConfigException;
- invalid inline variant data in the TOML is refused in ub-project's
  words, and every refusal keeps the ProjectConfigError as its cause;
- a diagnostic code this version does not know is a -v line only.
…nces

- relative toml paths: name the options whose toml value is resolved
  against the toml file's directory, instead of every path in the file,
  and drop the unscoped conf.py / -D claim;
- a toml file with [variants] and no [needs] is read, and even an empty
  [variants] stops the missing-[needs] warning;
- a conf.py value fills a key the LOCATION READ does not set;
- invalid inline variant data is among the refusals;
- a data file named in the toml is read twice during configuration
  initialisation, not once.
… and the empty-prefix cause

The validation round's two nits: a catch narrowed to (ProjectConfigError,
RecursionError) passed every test while a 5000-digit integer literal
crashed the build where master warns -- the unparseable-TOML test now
holds a case per exception class; and the empty-prefix configuration error
asserts its ValueError cause. The boundary's comment says what its
ValueError catch also converts until ub-project wraps it itself (#1995).
@chrisjsewell chrisjsewell added the pkg: sphinx-needs The sphinx-needs distribution (packages/sphinx-needs): its code, tests and docs label Sep 30, 2026
@codecov

codecov Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.74%. Comparing base (d68d10d) to head (49c24a6).
⚠️ Report is 27 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1996      +/-   ##
==========================================
+ Coverage   91.69%   91.74%   +0.04%     
==========================================
  Files         129      129              
  Lines       18155    18174      +19     
==========================================
+ Hits        16648    16674      +26     
+ Misses       1507     1500       -7     
Flag Coverage Δ
codelinks 93.42% <ø> (ø)
mounts 94.03% <ø> (+0.01%) ⬆️
pytests 91.50% <100.00%> (+0.09%) ⬆️
reports 88.55% <ø> (-0.21%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…error text

CI's py3.11 cells failed the deeply-nested case: the RecursionError there
read "maximum recursion depth exceeded in comparison", the suffix Python
adds when the limit trips inside a comparison rather than a call, which
depends on the stack depth at that moment. Assert the message's start and
the subtype tag; the same for the integer-limit case.
@chrisjsewell
chrisjsewell merged commit 7c098e1 into master Sep 30, 2026
35 of 36 checks passed
@chrisjsewell
chrisjsewell deleted the feat/needs-variants-table branch September 30, 2026 17:04
chrisjsewell added a commit that referenced this pull request Sep 30, 2026
…#1998)

sphinx-needs carried its own `validate_variant_data`,
`load_variant_data_file`, `deep_merge` and `resolve_variant_data`;
ub-project
(a runtime dependency since #1996) carries the one copy of the contract.
This deletes sphinx-needs' bodies and uses ub-project's.

**Deleted:** the four function bodies and `_SCALAR_TYPES` in
`sphinx_needs/variant_data.py`. The four names stay importable there as
plain re-exports of `ub_project`'s objects (a test pins `is`).

**Stays sphinx-needs':** `VariantDataError`, `VariantDataParsed`,
`lookup_variant_data`, `VariantDataProxy` — the `var` proxy for
filters and `.. if::`, the `<{ var.* }>` lookup and its error — and the
boundary: `resolve_variant_data_config` (priority 11) maps
`""` to no file (ub-project's reading contract §10.1) and re-raises
`ProjectConfigError` — and the one `ValueError` ub-project lets through,
an integer past Python's conversion limit
(#1995) — as `NeedsConfigException`, exactly the pair the TOML route
catches at priority 10.

**The five observable changes** (each pinned by a test):
1. Error texts on the `conf.py` / `-D` route are ub-project's — the TOML
route's words:

   | before | after |
   |---|---|
| `Variant data file not found: <p>` | `variant data file not found:
<p>` |
| `Invalid JSON in <p>: …` | `variant data file <p> is not valid JSON:
…` |
| `Variant data file must contain a JSON object, got list` | `variant
data file <p> must hold a JSON object, got list` |
| `var.x: expected str/bool/int/float/list/dict, got NoneType` |
`variant data file <p>: var.x: a value must be a str, bool, int or
float, an array or a table, got NoneType` (inline: without the file
prefix) |
| `var.x[1]: expected int, got str (arrays must be uniform type)` |
`var.x[1]: an array must hold one type, expected int but got str` |
| `var: expected a dict, got str` | `var: variant data must be a table,
got str` |
| `var: all keys must be strings, got int` | `var: keys must be strings,
got int 1` |
2. A directory or non-UTF-8 data file: raw `ExtensionError` →
`NeedsConfigException` naming the file (exit code 2 both).
3. The four functions raise `ub_project.ProjectConfigError`, not
`VariantDataError` (six unit tests moved; their regexes already
matched).
4. The resolved `needs_variant_data` is a new top-level dict, even with
no file (nested tables are still the `conf.py` objects) —
   observable only by identity.
5. A data file that cannot be read (permission denied): raw
`ExtensionError` → `NeedsConfigException … cannot be read` (exit code 2
both).

Everything else is identical: the old and new quartets were run over the
same 44 inputs (validate / load / merge / resolve): same
accept/reject set and same merge results on every row, the only outcome
difference being `resolve(inline, "")`, which the resolver's
`""` → `None` mapping keeps as "no file".

**Route parity:** the same bad data file declared in the TOML and in
`conf.py` now produces the same message, the TOML one only
prefixed `<toml>: [variants]: ` — pinned over eight bad-file shapes. The
oversized-integer case reads the same on both routes with
no prefix at all (ub-project raises it bare) and is pinned as exact
equality.

No manifest or lock change (`ub-project>=1.1.0` already declared;
`import-check-needs` against PyPI's 1.1.0 is green). Nothing under
`packages/ub-project`.

### Review
One adversarial reviewer (110 inputs and 25 whole builds through
master's code and this branch's: identical accept/reject sets and merge
results; only the enumerated class and wording differences), one fix
round (the fifth change, the `ValueError` catch, three pins) and a
validation round. Found on the way and filed: #1995 (ub-project's bare
`ValueError`, and its `/dev/null` message) and #1997 (a
`UV_PYTHON` cell run re-syncs the root `.venv` through the
`fetch-plantuml` deps task).
chrisjsewell added a commit that referenced this pull request Sep 30, 2026
…ct, keeping `[needs] variant_data*` (#2000)

### What

sphinx-mounts now reads its variant map from `[variants]` — the table of
`ubproject.toml` that sphinx-needs 9.0.0 reads (#1996) and
every tool reading the file is to share — through `ub-project`, the
shared reader, and keeps reading the legacy `[needs]
variant_data` / `variant_data_file` keys. Its private copy of the
variant-data functions (validate / load / deep-merge / resolve) and
`VariantDataError` are deleted: the workspace now holds one
implementation, ub-project's. `ub-project>=1.1.0,<2` becomes a runtime
dependency.

The map still comes from sphinx-needs whenever it is loaded, decided
from the config alone (no import, no version gate):

- **sphinx-needs not loaded** (not installed, or not in `extensions`):
`ub_project.read_variants` reads the top-level `[variants]` /
`[needs]` of the file sphinx-mounts already reads, and its findings are
reported here.
- **loaded, non-empty map**: unchanged — the confvals, re-merged with
ub-project's idempotent `resolve_variant_data`; the TOML's variant
data is not read. A `needs_variant_data_file` that is not a path (8.3.1
copies the TOML's value unvalidated) is refused.
- **loaded, EMPTY map**: the file is read (findings unreported) only for
the mispointing guard, which now covers `[variants]` too. When
sphinx-needs is pointed at the file, it is read as sphinx-needs read it:
a key overridden with `-D` is removed from both locations
  first, exactly as sphinx-needs removes it.

### Behaviour (enumerated)

Well-formed `[needs]` projects are unchanged in every installation (not
loaded, sphinx-needs 8.3.1, 8.5.0 and this repository's;
pointed, unpointed, elsewhere; every `-D` spelling — measured on a
30-project × 4-installation × 7-`-D`-form grid, and every legacy
cell of a 432-build `-D` grid on this branch has master's exit code).
What changes:

1. A `[variants]`-only project with sphinx-needs not loaded builds its
gated content; before, the table was not read and every gated
   file/mount was silently excluded.
2. A `[variants]`-only project with sphinx-needs loaded but unpointed,
or pointed at another file, is refused by the guard — as a
   `[needs]` project already was.
3. **New refusal** — see below.
4. Both locations set, sphinx-needs not loaded: `[variants]` wins,
whole, with one `mounts.variant_data_location` warning per ignored
`[needs]` key (new subtype; a `-W` build that passed now fails —
`suppress_warnings = ["mounts.variant_data_location"]`).
5. Unknown key in `[variants]`, sphinx-needs not loaded:
`mounts.unknown_key`. Newly red under `-W` only when the data is in
`[needs]`
and `[variants]` holds only unknown keys (a project with data in
`[variants]` already failed `-W` on
   `mounts.variant_rule_unevaluable`).
6. Data read from the legacy location, sphinx-needs not loaded: one `-v`
line per key; no warning (the deprecation warning is a later
   step).
7. Newly refused where sphinx-mounts reads the data: `variants = "x"`; a
malformed `[variants]` (missing file, `data_file = ""`,
invalid JSON/shape); `needs = "x"` with a gating key declared (was
silently no data).
8. **Less strict:** a malformed `[needs] variant_data*` shape is no
longer refused by a build that does not use it (no gating key; or
sphinx-needs supplies a non-empty map, e.g. from `conf.py`).
sphinx-needs still refuses it when pointed at the file.
9. Messages for unreadable data are ub-project's and name the TOML and
table: a directory is reported as a directory;
`variant_data_file = ""` is refused as empty rather than read as the
TOML's directory; a JSON integer beyond Python's conversion
limit (#1995) is the clean `mounts.variant_data_unreadable` refusal
instead of a raw "Handler … threw an exception"; with
sphinx-needs not loaded, a `[needs] variant_data*` of the wrong type is
`mounts.variant_data_unreadable` instead of
`TomlConfigError`; with 8.3.1 pointed, `variant_data_file = 1` is
`mounts.variant_data_unreadable` naming the value.
10. A TOML data file that is missing or malformed with sphinx-needs
loaded but not reading that file: still refused, now as the read
error, and the message still names the one-line `needs_from_toml` fix.

### The new refusal

sphinx-needs pointed at this file (`needs_from_toml`), its map EMPTY,
while the file's `[variants]` — read as sphinx-needs read it, with
any `-D`-overridden key removed — yields a NON-empty map. A sphinx-needs
pointed at the file resolves the file's own map unless it did
not READ the table: because it predates 9.0.0 (measured on 8.3.1 and
8.5.0), because it abandoned the file on a `[needs]` error before
reaching `[variants]` (measured: `[needs] schema = "x"` on this
repository's sphinx-needs), or because `needs_from_toml_table` scopes it
to `[<prefix>.variants]`. Before this PR the gated content was silently
lost in all three. The message names the file, the gating keys,
and the cause — "a sphinx-needs before 9.0.0 does not read it, and a
later one does not when it could not load this file's `[needs]`
table (its `needs.config` warning above says why)", with the remedy
"upgrade sphinx-needs to 9.0.0 or later and fix any `[needs]` error
it reports, or keep the two keys in `[needs]`"; or, under a prefix
(rendered as TOML, `[tool."a.b".variants]`), "move the table under the
prefix (merging it into the one there, if any), or stop setting
`needs_from_toml_table`" — and ends `[mounts.variant_data_unreadable]`
(hard, not suppressible). Legitimately empty data (a data file of `{}`,
an empty `[variants.data]`) is not refused, and a `-D` cannot
make the check fire: sphinx-needs removed the overridden key from this
file, and so does the check. Because the key is removed as
sphinx-needs 9.0 removes it, on an older sphinx-needs an override of
`variant_data` / `needs_variant_data` also drops a
`[variants.data]` that release never read (rc 0, as on master).

### What is deliberately not fenced

- **Both locations set + sphinx-needs < 9.0.0 pointed.** Both tools use
`[needs]` and `[variants]` is lost with per-rule warnings only —
unless a `-D` empties the `[needs]` map, when the check reads
`[variants]` and refuses. Catching the rest needs "confval map ≠ TOML
map", which false-positives on a correct 9.0 project whose `conf.py` map
merges with the TOML's file. Documented as a mid-migration
  shape ("finish the move, or require sphinx-needs 9.0.0").
- **A `-D` over a file sphinx-needs is not reading** is not applied to
it: that file keeps every declaration, so an unpointed project
  is still refused with the pointing fix, whatever `-D` says.
- **Diagnostics when sphinx-needs is loaded**: never reported by
sphinx-mounts (no double report).
- A leafless `[variants.data] a = {}` counts as non-empty (an old
sphinx-needs is refused where a 9.0 one warn-and-excludes to the same
  document set); harmless, named in the guard's docstring.
- The example project (`tests/example/`) stays on `[needs]`: ubCode,
which reads that file, has no `[variants]` reader yet.

### Docs / contract

- `docs/configuration.rst`: "Where the variant data comes from"
rewritten for `[variants]` (legacy still read, whole-location
precedence, top-level only, the report-when-not-loaded rule, the `-v`
line), linking sphinx-needs' section by URL; the refusal note
gains the new refusal (both causes, the prefix, the `-D` rule, the empty
`conf.py` map) and the residual; the examples use
`[variants.data]`; the warning table gains
`mounts.variant_data_location` and widens `mounts.unknown_key`; the
hard-codes paragraph
  widens `mounts.variant_data_unreadable`.
- `design/mapping-contract.md` §7.1, §11 (a `[variants]`-only file is
read here and not by ubCode until its reader ships), §12.4, §12.6
(ub-project computes the map; both locations; the three routes; the `-D`
rule; the joined anchor).
- `packages/sphinx-mounts/AGENTS.md` lists `variants.py`.
- `packages/sphinx-needs/docs/configuration.rst`: the `[variants]` note
now says sphinx-mounts reads it from its next release, 0.3.0.
- Changelog: the `Unreleased` bullets in
`packages/sphinx-mounts/docs/changelog.rst`.

### Tests

- New `tests/test_variants_table.py` (50 items, Sphinx-level; every
sphinx-needs-present case simulates it with `_stub_conf`, because
the release compat cell runs against the PUBLISHED sphinx-needs, which
does not read `[variants]`): the not-loaded route (map built,
location warnings one per key + suppression, unknown key + suppression,
legacy not-a-warning / `-v` line, `variants`/`needs` not a
table, malformed `[variants]`, the oversized integer), the guard
(unpointed, elsewhere, the new refusal naming both causes, three
prefix renderings, the route-C refusal in four sub-cases with and
without the pointing fix), the new cell's false positives
(legitimately empty ×2, `-D` of every declared key in both spellings ×4,
`-D` over a missing file in both locations and spellings
×4), what `-D` must not excuse (an old sphinx-needs over
`[variants.data]`; a file sphinx-needs is not reading), the new cell
staying
off `[needs]`, a non-path confval data file, the dotted `-D` on route B,
no double report ×2, and an AST fence that `sphinx_mounts`
never imports `sphinx_needs`. Warnings asserted by code and required
phrases, never by ub-project's wording.
- `_stub_conf` can register `needs_from_toml_table`; the route-B
stand-down test also asserts its status line.
- `tests/test_variant_data.py` deleted (33 items, all on the deleted
copy): ub-project's suite pins a superset of its shapes and both
  idempotency properties; the Sphinx-level three-cell tests stay.
- Red first, then mutation: every fence of a behaviour change failed
against the unfixed reader; the fences of a false positive are
proven by mutation — 12 mutations of the new code (reinstating the old
exemption, stripping on every route, never stripping, each
conjunct of the new cell, `declares`, route-C reporting, the
`ValueError` catch, the `-v` line, the absent-route map, the prefix
branch, a guarded `sphinx_needs` import), each red, plus the wrong
versions review found surviving (the strip's scope and
spellings, in-place mutation, a `PathLike` confval, `""` → `None` on the
confval route, the prefix renderer's escaping), now red.
- `poe test-mounts`: 959 passed, 2 deselected, 0 skipped;
`test-mounts-sphinx7` (Python 3.11): 950 passed, 2 xfailed
(pre-existing).
End to end: 112 builds across sphinx-needs not loaded / 8.3.1 / 8.5.0 /
this repository's, as designed; a 432-build grid of `-D` forms
in which every legacy cell's exit code equals master's; the Bazel
example builds identical logs before and after, both editions.

### Toolchain (dependency, lock, compat cell)

- `ub-project>=1.1.0,<2` in `dependencies` (the tight floor
`check-workspace` demands). sphinx-mounts joins the set re-floored by
every
  ub-project release (`propagate_floors.py`).
- `uv.lock`: master's lock plus the two lines of the new edge in the
`sphinx-mounts` block — not a re-lock (a fresh `uv lock` rewrites
~224 lines of fork markers). `uv lock --check` passes on 0.12.15 and on
the hook's 0.12.9.
- `compat-requirements.txt` unchanged: `ub-project` is a runtime
dependency, so the compat cell resolves it from PyPI through the
wheel's metadata; `poe import-check-mounts` shows the built wheel
pulling `ub-project==1.1.0` from the index and importing all 8
modules. The suite passes in release.yaml's compat recipe against the
published sphinx-needs 8.5.0 (measured; the tests that
simulate a present sphinx-needs are stub-only, so that stays true until
sphinx-needs 9.0.0 is published).
- No `env_version` change: the document set still changes only through
`exclude_patterns` / `mounts`, both `rebuild="env"`.

**Follow-ups, not this PR:** ub-project should export `render_path`;
sphinx-needs: `-D needs_variant_data=x` is rejected by Sphinx yet
stays in `config.overrides`, and its `-D` removal then drops the inline
map — an issue follows.
chrisjsewell added a commit that referenced this pull request Oct 14, 2026
Six members of the workspace released together, in one pull request and
three tag waves: sphinx-needs 9.0.0 (the Sphinx half of the
derived-values lockstep with ubCode's next release) and the five
packages around it. Everything mechanical is `uv run poe bump`'s, one
commit per member; the prose, the gap bullets and the version-marker
sweep are by hand, each claim measured against the tree.

### What this releases

| member | from → to | level | why |
|---|---|---|---|
| sphinx-ai-index | 0.1.0 → **1.0.0** | major | the `ai_docs_index.json`
it writes has been the format's `"1.0"` since the first release, and
ubCode reads it in production — that is the stability 1.0 declares
(#2034); this is also its first release from this repository, on the
workspace's Python 3.11 / Sphinx 7.4–9 / docutils 0.21 floors |
| sphinx-mounts | 0.2.0 → **0.3.0** | minor (0.x) | variant data from
`[variants]` through ub-project, with new refusals and two new
`-W`-failing warnings; Python 3.11 again; docutils 0.21; first release
from this repository |
| sphinx-needs | 8.5.0 → **9.0.0** | major | computed values (derived
fields, dependency order), `needextend` filters and the needs.json
readers can change need data in certain corner cases; `[[…]]` in content
and `need_func` removed; Python 3.11; `[variants]`; `needstable.js` |
| ub-test-reports | 1.0.0 → **1.1.0** | minor | parser and converter
fixes that add `ReportReadError` and change which exceptions escape and
what a nested-suite report converts to |
| sphinx-codelinks | 1.4.0 → **2.0.0** | major | requires Sphinx-Needs
9; `get_rst` / `[analyse.marked_rst]` refused, the `@rst` blocks
replaced by multi-line needs; first release from this repository |
| sphinx-test-reports | 2.0.0 → **3.0.0** | major | the Sphinx-free half
is ub-test-reports; the import name moves to `sphinx_test_reports`;
requires Sphinx-Needs 9 and ub-test-reports 1.1 |

ub-project stays at 1.1.0: nothing to release.

The six versions, the `__version__` literals, the floors
(`sphinx-ai-index>=1.0.0,<2` in four `docs` extras;
`sphinx-needs>=9.0.0,<10` in codelinks and test-reports;
`ub-test-reports>=1.1.0,<2` in test-reports), the docker fallback
(`sphinx-needs-v9.0.0`), the lock and the six changelog headings are `uv
run poe bump`'s, one commit per member in the planner's order.

### What was added by hand

- **sphinx-needs 9.0.0**: bullets for the changes the entry had not
recorded — ‼️ `[[…]]` in need content is plain text and the `need_func`
role is removed (#1919); ✨ the needtable script (`needstable.js`,
`needs_table_page_size` / `needs_table_page_sizes`, Excel / PDF export
and column reordering removed; #1920); ✨ `[variants]` read through
ub-project, a new dependency, with two new warning types (#1996, #1998);
⬆️ docutils 0.21 (#1977); ⬆️ jsonschema-rs up to 0.58 (#1851, #1980); 👌
deterministic CSS order (#1914); 🔧 the uv workspace, the
`#subdirectory=packages/sphinx-needs` git install and the
`sphinx-needs-v<version>` tags (#1839, #1846), in a new "Internal
changes" section; 🔧 the sdist without `tests/` / `performance/` (#1905);
🐛 needgantt's integer completion (#1870); and the derived-fields bullet
cites #2204 and #2205.
- **sphinx-mounts 0.3.0**: the move bullet (#1850).
- **sphinx-codelinks 2.0.0**: 🐛 `get_current_rev` reads `packed-refs`
(#1871); the ‼️ floor bullet says Sphinx-Needs 9.0 / `>=9.0.0,<10`;
`codelinks write rst` "will be removed in 3.0.0"; the documentation is
served by Read the Docs; the `ingest_need_record` note trimmed.
- **sphinx-test-reports 3.0.0**: the floors it ships
(`sphinx-needs>=9.0.0,<10`, ub-test-reports 1.1.0).
- **ub-test-reports 1.1.0**: the UTF-8 bullet links #2024.
- **The six summary paragraphs**, one per entry (sphinx-ai-index's
replaces its old lead).
- **Pages outside the changelogs** (each states what this release
ships):
- sphinx-codelinks' `codelinks write rst` deprecation
(`WRITE_RST_DEPRECATED` in `cmd.py`, and `docs/components/write.rst`)
now promises removal in 3.0.0, not 2.0.0, and is dated 2.0.0, not an
uncut 1.5.0: 2.0.0 ships with the command.
- `docs/basics/introduction.rst` says `src-trace` renders multi-line
needs.
- sphinx-needs' `docs/directives/choose.rst` says `versionadded::
9.0.0`, not an uncut 8.6.0, and `docs/configuration.rst` says
sphinx-mounts reads `[variants]` "from 0.3.0", not "from its next
release".
- sphinx-test-reports' `docs/install.rst` names the Sphinx-Needs 9 floor
(`>=9.0.0,<10`).
- Comments and agent notes naming the old `>=8.5.0,<9` floor
(`compat-requirements.txt`, two `AGENTS.md`, `src_trace.py`) name 9.0.0.
- A sweep of all 216 version markers (`versionadded` / `versionchanged`
/ `deprecated`) in the six members' docs and sources, against each
member's released versions, found 13 more that named a release never
cut. Each now names the release that shipped it: mounts 0.2.0 ×2,
sphinx-needs 3.0.0 ×7, 4.0.0 ×1 and 7.0.0 ×1, test-reports 1.1.0 ×2.

### Merge and tag order

Merge RIGHT BEFORE tagging. **Re-stamp `:Released:` on the merge day
first** (it is stamped 2026-10-14, the earliest tag day); one line per
file, each in its own format:

| file | line | format |
|---|---|---|
| `packages/sphinx-ai-index/docs/changelog.rst` | l.11 `:Released:
2026-10-14` | ISO `YYYY-MM-DD` |
| `packages/sphinx-mounts/docs/changelog.rst` | l.11 `:Released:
2026-10-14` | ISO |
| `packages/sphinx-needs/docs/changelog.rst` | l.12 `:Released:
14.10.2026` | `DD.MM.YYYY` |
| `packages/ub-test-reports/docs/changelog.rst` | l.11 `:Released:
2026-10-14` | ISO |
| `packages/sphinx-codelinks/docs/changelog.rst` | l.11 `:Released:
14.10.2026` | `DD.MM.YYYY` |
| `packages/sphinx-test-reports/docs/changelog.rst` | l.11 `:Released:
14.10.2026` | `DD.MM.YYYY` |

Then tag the squash commit `<sha>` explicitly (so nothing merged later
can slip into a release), in three waves, each after the previous wave's
`publish` jobs are green — the plan job's check 4 refuses a member whose
workspace dependencies (extras included) are not on PyPI at the version
this tree builds:

```bash
# wave 1 — dependencies already on PyPI (ub-project 1.1.0) or none
git tag sphinx-ai-index-v1.0.0 <sha> && git push origin sphinx-ai-index-v1.0.0
git tag ub-test-reports-v1.1.0 <sha> && git push origin ub-test-reports-v1.1.0
# wave 2 — once sphinx-ai-index 1.0.0 is on PyPI
git tag sphinx-mounts-v0.3.0 <sha> && git push origin sphinx-mounts-v0.3.0
git tag sphinx-needs-v9.0.0 <sha> && git push origin sphinx-needs-v9.0.0
# wave 3 — once sphinx-needs 9.0.0 and ub-test-reports 1.1.0 are on PyPI
git tag sphinx-codelinks-v2.0.0 <sha> && git push origin sphinx-codelinks-v2.0.0
git tag sphinx-test-reports-v3.0.0 <sha> && git push origin sphinx-test-reports-v3.0.0
```

The bare `9.0.0` tag is pushed by `release.yaml`'s last step for
sphinx-needs ("Push the bare companion tag"); it is what moves Read the
Docs' `stable` and keeps `git+…@<version>` pins working. Only if that
step fails: `git tag 9.0.0 sphinx-needs-v9.0.0 && git push origin
refs/tags/9.0.0`. A tag pushed too early fails only its plan job, with
nothing published: re-run it once the dependency is up. After a green
`publish`, a red later job is re-run with **Re-run failed jobs**, never
*Re-run all jobs*.

### Before wave 1

Five of these PyPI projects release through `release.yaml` for the first
time — sphinx-ai-index, sphinx-mounts, sphinx-needs (8.5.0 went out
through the old token-based workflow), sphinx-codelinks and
sphinx-test-reports. Each must list the trusted publisher **owner
`useblocks`, repository `sphinx-needs`, workflow `release.yaml`,
environment `pypi-<dist>`**. The GitHub side is ready (the five
`pypi-<dist>` environments exist, each with the tag policy `<dist>-v*`);
the PyPI side can only be checked by a project owner. A wrong publisher
fails `publish` with nothing uploaded — fix it and re-run from the same
tag — but sphinx-ai-index's is the very first tag, so it would stall the
whole train.

### Rehearsal

Two rehearsals can pass before the merge, from this branch:

```bash
gh workflow run release.yaml --ref release/9.0.0-train -f tag=sphinx-ai-index-v1.0.0
gh workflow run release.yaml --ref release/9.0.0-train -f tag=ub-test-reports-v1.1.0
```

Dispatched from this branch at `9a2dd5ec`: run
[38479550037](https://github.com/useblocks/sphinx-needs/actions/runs/38479550037)
(`sphinx-ai-index-v1.0.0`) and run
[38479553168](https://github.com/useblocks/sphinx-needs/actions/runs/38479553168)
(`ub-test-reports-v1.1.0`). A dispatch runs `plan` and `build` (the
resolver gate and the compat cell) and never `publish`. It must name
this branch: at `master` the tag's version is not the tree's (check 2).
The other four cannot pass yet, because check 4 stays fatal in a
rehearsal: sphinx-mounts and sphinx-needs need sphinx-ai-index 1.0.0 on
PyPI (their `docs` extras), sphinx-codelinks and sphinx-test-reports
need sphinx-needs 9.0.0 (and test-reports ub-test-reports 1.1.0). They
can be rehearsed from `master` once the wave before them has published.

### What goes red and why

- **Docs-Linkcheck** (not a required check): the new
`8.5.0...sphinx-needs-v9.0.0` compare link 404s until the
`sphinx-needs-v9.0.0` tag exists.
- **`uv run poe import-check-codelinks` / `import-check-reports`**: red
until wave 2 publishes — "only sphinx-needs<=8.5.0 is available and
sphinx-codelinks==2.0.0 depends on sphinx-needs>=9.0.0,<10". Run them on
`master` between waves 2 and 3: red then would be a real missing symbol.
The other five import checks are green on this branch.
- **Docker**: the fallback ref becomes `sphinx-needs-v9.0.0`, so the
`docker/**` pull-request and manual-dispatch path, red since the move,
goes green once that tag exists. This PR does not run it (its trigger is
`docker/**` only), and the tag push builds the image from the tag
itself.

### For a human (outside the repository)

- **PyPI trusted publishers**: the five checks under "Before wave 1".
- **codelinks.useblocks.com deep links**: the old GitHub Pages site
served pages at the root (`…/components/analyse.html`). Under Read the
Docs they 404 and live under `/en/latest/`. A Read the Docs page
redirect (old path → `/en/latest/<path>`) on the `sphinx-codelinks`
project would keep bookmarks and inbound links working.
- **Read the Docs `stable` for sphinx-mounts and sphinx-codelinks**
serves the Sphinx-Needs 8.5.0 documentation today (both projects build
from this repository, whose highest PEP 440 tag is sphinx-needs' bare
`8.5.0`). After this train it follows the bare `9.0.0` tag, then every
later sphinx-needs release, and never a mounts- or codelinks-only one.
Consider deactivating `stable`, or making `latest` the default version,
on those two projects.

### ubCode

ubCode's next release names sphinx-needs 9.0.0 as the version its
derived-values reading pairs with. This changelog names no ubCode
version, by design: it keeps the entry's "phase-2 release" wording. The
ubCode release pull request is prepared separately.

Closes #2034
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg: sphinx-needs The sphinx-needs distribution (packages/sphinx-needs): its code, tests and docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant