✨ Add downstream seams: opt-in numbering, node factories, app lexers, markup helpers - #15
Conversation
… helpers
The bespoke "example" directives in myst-parser and sphinx-needs can only be
replaced by a subclass of SyntaxExampleDirective if the subclass reaches exact
parity with what those projects ship. Auditing both turned up five gaps.
- New `syntax_example_numbering` config value (bool, default False, "env"
rebuild): the default title becomes a per-document numbered label carrying
any argument as a subtitle ("Example 1", "Example 5: the filter option") —
the shape sphinx-needs' `need-example` produced, so its docs can move to the
canonical directive. A matching option is being added to ubCode's Rust engine
under the same config name, to produce the same output.
The check lives in `format_title`'s canonical body, so an overriding subclass
opts out; numbering only decorates a non-empty `default_title`; the counter
is keyed by the registered directive name, case-folded because
reStructuredText directive names are case-insensitive, so aliases count
independently but two spellings of one directive share a run.
- New `build_wrapper_node` / `build_render_node` seams, deciding the node type
and any extra attributes. myst-parser's docs need sphinx-design divs
(`is_div`, `design_component`), whose visitor omits the `container` class that
would otherwise attract Bootstrap and pydata-theme `.container` layout rules.
Classes stay orthogonal to node type: the class attributes remain the single
source of them and `run` applies all three uniformly, merging with anything a
factory set, so an override neither repeats them nor can drop them and render
silently unstyled.
- New `per_document_number()` helper — the counter behind the config value,
exposed for a `format_title` that numbers on its own terms.
- New `nested_parse_text()` helper, parsing a *string* of markup with the host
document's parser and the directive's source attribution: the seam for
myst-parser's `:alt-output:`, which the content-list-based `render_into`
default cannot serve.
- `:highlight:` now honours a lexer added with `app.add_lexer`. Only Pygments'
global registry was consulted, so a project-local lexer (as myst-parser's own
docs register under "myst") was invisible and silently fell back. Sphinx's
`lexer_classes` / `lexers` are read by name and checked for mapping-ness, so
a future rename degrades to the Pygments-only probe rather than crashing.
Sphinx's built-in aliases come along, so `:highlight: none` now works too.
Default behaviour is unchanged and the existing tests pass untouched. The new
tests add an autouse fixture restoring `sphinx.highlighting`'s registries around
each build, since `add_lexer` writes into module globals that `docutils_namespace`
does not cover.
chrisjsewell
left a comment
There was a problem hiding this comment.
Adversarial review — correctness, performance, durability, complexity, mission fit
Verdict: solid, merge-worthy work — with one genuine test gap that should be fixed first, and one stale docstring claim. Everything the PR body asserts that I could execute, I did execute, and it held up. The one claim that didn't fully hold is "each test was mutation-checked" — true for the new behaviors, but the refactor quietly un-covered an old one.
What was verified (not just read)
- 34/34 tests pass on Sphinx 9.0.4 and on the true floor 7.2.6 (docutils 0.20.1, myst-parser 4.0.1); ruff +
mypy --strictclean against both ends via the dual pre-commit hooks; docs build-nWclean. - Default behavior is byte-identical: all 17 pre-existing tests pass unchanged.
- The
temp_datascoping claims are real, not prose. Probes run: a 3-document numbered project builds correctly; appending an example to one doc and rebuilding incrementally reproduces full-build numbers everywhere (unchanged docs untouched, re-read doc correct); aparallel=2read produces correct per-document numbers;envis the right rebuild category. - The durability guard works: deleting both
sphinx.highlightingregistries (simulating a future rename) degrades_lexer_availableto the Pygments-only probe —pythonstill resolves,nonecleanly stops resolving, no crash.
Findings
1. (should-fix) The Pygments-resolution path is now a mutation survivor. Coverage fell 100% → 99.18%, and the uncovered line is _lexer_available's return True after a successful get_lexer_by_name (src/sphinx_syntax_example/__init__.py:161). The cause is subtle: python is in sphinx.highlighting.lexer_classes, so test_highlight_override — which used to exercise the Pygments path — now short-circuits at the new Sphinx-registry check. Verified empirically: hard-breaking the Pygments branch (return True → return False) passes all 34 tests. A regression in the code path every plain-Pygments :highlight: value (ruby, json, …) depends on would ship green. One-line fix: a build test with :highlight: ruby, or assert _lexer_available("ruby") alongside the existing registry asserts.
2. (should-fix, one sentence) The honesty pass missed a sentence. The module docstring still claims the extension "relies only on the stable public get_filetype / config.source_suffix API" — no longer true now that a documented feature reads sphinx.highlighting.lexer_classes / lexers (semi-private, name-guarded). The guard is exactly right; the sentence isn't.
3. Nitpicks. Docstring and PR body say the counter key is "case-folded" but the code uses .lower() (identical for realistic names — align the word or the code). per_document_number(key="") silently falls into the default key via key or …; key is None would be stricter. The README's DivExample snippet uses nodes.container without showing the nodes import, and AltOutputExample calls an undefined self.alternative_markup() — fine as illustration, breaks on paste. test_app_registered_lexer_does_not_leak is order-dependent (vacuous when run alone) — acceptable as a fixture canary, worth a comment saying so.
Design observations (accepted, not defects)
- Read-time numbering means content under
ifconfig/onlyconsumes numbers even when excluded from output — inherent to the approach and the same as sphinx-needs. - A titled example still consumes a number (
Example 2: Custom) — intended, matches the need-example shape, tested. - The source pane gets no factory (
literal_blockis hard-coded) — asymmetric with the two new seams, but no motivating case; noting it so the asymmetry is a decision, not an accident. - If Sphinx ever renames the registries, app-lexer
:highlight:values degrade silently (verbose note only). That's the documented tradeoff of the guard, and the right one for a-W-safe directive.
Correctness, performance, complexity
format_title's numbering logic handles all the edge combinations correctly (off/on × arg/no-arg × empty default_title — all tested); _apply_classes merges in place without duplicates (tested, and the orthogonal factory/classes contract is genuinely good design — the stylesheet can't be silently orphaned by an override). nested_parse_text's attribution is honest about being approximate, and the MyST-vs-RST offset test is unusually careful. Performance is a non-issue: the new probes are dict lookups that short-circuit before Pygments' plugin scan, and numbering is one temp_data get/set per directive. Complexity growth is real (config value + two factories + two helpers) but each addition is small, orthogonal, and named after a specific downstream consumer.
Mission fit
Yes — with conviction. The module docstring has named need-example-style numbering and myst-parser's :alt-output: as the intended downstream cases since v0.1.0; this PR builds precisely those seams and nothing speculative. The old "pure function / no auto-numbering" pillar is formally weakened (output can now depend on document position), but the rewrite keeps the load-bearing guarantees — parallel safety and incremental reproducibility — verified here empirically rather than taken on the prose's word. The rewritten paragraphs are accurate; finding 2 is the only spot the honesty pass missed.
Recommendation: address findings 1 and 2 (both are minutes of work), then merge. The nitpicks can ride along or wait.
Generated by Claude Code
…tpicks - Add a build-level test for a lexer only Pygments provides (`:highlight: ruby`, absent from both Sphinx registries). The Pygments arm of `_lexer_available` had become a mutation survivor: every other lexer under test short-circuits at the Sphinx-registry check, so hard-breaking `return True` passed the whole suite and coverage sat at 99.18%. The test asserts its own precondition — that `ruby` is in neither Sphinx registry — so a future registration cannot make it stop covering that arm while still passing. Coverage back to 100%. - Correct the module docstring's claim to rely "only on the stable public get_filetype / config.source_suffix API": that stopped being true when the lexer probe started reading `sphinx.highlighting`'s registries, which have no public accessor. It now names the exception and what it costs if it breaks. - Say "lowercased" where the code calls `str.lower`, not "case-folded" (`str.casefold` is a different operation). - `per_document_number` keys off `key is not None` rather than truthiness, so an explicit `""` is a real key rather than silently the default one. - Make the README subclassing snippets paste-runnable: both now carry their imports, and `AltOutputExample` takes its markup from an `:alt-output:` option instead of calling an undefined method. The recipe is pinned by a test, since it doubles as the myst-parser parity claim — including that an extended `option_spec` and `super().render_into()` both work. - Note in `test_app_registered_lexer_does_not_leak` that it is a canary for the registry-isolation fixture, order-dependent by design and vacuous if run alone.
|
Both should-fix findings and the nitpicks are addressed in 52b7567:
Re-verified on the head commit: 36 tests pass on Sphinx 7.2.6 / 8.2.3 / 9.1.0, |
The bespoke "example" directives in myst-parser and sphinx-needs can be
replaced by a subclass of
SyntaxExampleDirective— but only if the subclassreaches exact parity with what those projects already ship, since neither will
accept a regression in its own rendered docs. Auditing both against v0.1.1 turned
up five gaps. This PR closes them. Every change is additive: default behaviour is
byte-identical, and the existing tests pass unchanged.
Opt-in numbering —
syntax_example_numberingsphinx-needs is renaming its
need-exampleusages to the canonicalsyntax-exampledirective, and wants its numbered rubrics back as a featurerather than a subclass hack. New config value (bool, default
False,envrebuild category — it changes title text written into doctrees, so toggling it
must re-read sources):
which is exactly the shape
need-exampleproduced ("Example 5:filteroption"). A matching option is being added to ubCode's Rust engine under the
same config name, to produce the same output.
Design decisions worth reviewing:
format_title's canonical body, so a subclass thatoverrides
format_titleopts out wholesale rather than being numbered behindits own back.
default_titleis aprecondition. A subclass with
default_title = ""is untouched by the config:no argument still means no rubric, an argument still means exactly that
argument.
numbers independently of
syntax-examplein the same document.Honesty pass on the prose. The README and module docstring claimed "no
auto-numbering and no cross-document state / a pure function of the directive
instance". Numbering is now possible, so those paragraphs are rewritten around
what is actually true and actually load-bearing: the state is per document
(
env.temp_data, which Sphinx replaces for each source file it reads), and adocument is exactly the unit of both parallel reading and incremental
rebuilding. Re-reading one changed file reproduces the numbers of a full build,
and nothing crosses between parallel workers. No cross-document state, ever.
build_wrapper_node()/build_render_node()myst-parser's docs wrap examples in sphinx-design-style divs (
is_div=True,design_component="div"), so that extension's overridden container visitoremits
<div class="myst-example docutils">without thecontainerclass —which would otherwise attract Bootstrap and pydata-theme
.containerlayoutrules and break the frame. Previously the only way to get a non-
containernodewas to re-implement
run(), i.e. to fork the whole assembly. These two factoriesare the seam;
run()still sets the source info on whatever the wrapper factoryreturns.
The two responsibilities are kept orthogonal: a factory decides the node
type and any extra attributes, and
run()applieswrapper_classes/render_classesto whatever comes back (merging with any class the factory set,without duplicating) — consistent with how it already applies
source_classesto the literal block. The class attributes therefore remain the single source of
the CSS classes: an override neither has to repeat them nor can silently drop
them and render unstyled. The default factories return a bare
nodes.container(), and default output is byte-identical.Sphinx-app-registered lexer awareness
_lexer_available()consulted only Pygments' global registry, so a lexer addedwith
app.add_lexer()was invisible: myst-parser's docs register a localMystLexerunder "myst", and:highlight: mystsilently fell back to theinferred language (as did
default_language'smystprobe). It now checkssphinx.highlighting'slexer_classesandlexersfirst — the same registriesPygmentsBridge.get_lexerresolves against — then Pygments. Both moduleattributes are read via
getattrand checked for mapping-ness, so a rename orreshape in a future Sphinx degrades to the Pygments-only probe rather than
crashing; verified against the supported floor (7.2) and current (9.x). The probe
stays a dict membership test, read-only and side-effect free.
Side effect worth noting: Sphinx's own built-in aliases live in the same
registry, so
:highlight: none— a valid Sphinx highlight language that Pygmentsdoes not resolve — is now honoured instead of falling back. Documented in the
changelog and covered by a test.
per_document_number(key=None)The counter behind
syntax_example_numbering, exposed for aformat_titleoverride that numbers on its own terms — the seam sphinx-needs'
need-exampleneeds for its numbered rubrics. Default key is namespaced by the registered
directive name, case-folded, since reStructuredText directive names are
case-insensitive and two spellings of one directive must share a run. The
README's numbering recipe, which hand-rolled a
temp_datacounter, issimplified to a one-liner using it.
nested_parse_text(text, container)render_intoreceives the directive's content as a pre-built line list; anoverride that renders something else — myst-parser's
:alt-output:, where therender pane shows alternative markup while the source pane shows the verbatim
content — has only a string. This helper wraps it in a
StringListwith thedirective's own source and content offset, so a warning raised by the parsed
markup points into the directive rather than at line 1 of an anonymous block, and
routes it through
state.nested_parse— the host document's parser, so thesame call renders reStructuredText in an
.rstfile and MyST in a.mdone,with no format sniffing.
Tests
Seventeen new tests in the existing style (build-level bar one), plus an autouse
fixture that snapshots and restores
sphinx.highlighting's lexer registriesaround each test (
app.add_lexerwrites into module globals, which — unlike thedocutils registrations
docutils_namespacealready handles — would leak betweenin-process builds).
Covered:
is_divattributes surviving into the doctree from both factories withthe class attributes still applied and source info still set on the wrapper, and
a factory-set class merged rather than replaced; an
app.add_lexer'd lexerhonoured via
:highlight:underwarningiserror=True, with the name assertedunknown to Pygments before and after, plus
:highlight: none; the counterresetting across a two-document project and shared across two spellings of one
directive name;
nested_parse_textin both an RST and a MyST host document, plusits warning attribution in each; and, for numbering, default-off, on,
per-document reset, alias independence, an overridden
format_titleuntouched,and a labelless subclass untouched.
Each new test was mutation-checked: reverting the behaviour it covers makes it
fail. Note that the MyST attribution test pins the
lineno-vs-content_offsetconfusion (5 and 0 there, against 4 and 5 in the reStructuredText equivalent),
not
0vscontent_offset— MyST's own value is 0 for a plain fence, which thetest docstring says.
Verified:
pytest(34 passed) on Sphinx 7.2.6, 8.2.3 and 9.1.0;ruff check,ruff format --check,mypy --strictagainst both the 7.2 floor and 9.x;sphinx-build -nW --keep-goingondocs/.No version bump — this is the 0.2.0 feature set staged for a later release
commit.