Repository navigation
✨ needflow: portable direction, link-label and legend options - #1782
Merged
Merged
Conversation
added 6 commits
August 20, 2026 09:55
A diagram says which way it flows as an *intent* -- down, up, right, left -- and each engine spells that in its own language, so the same document renders the same way on either engine instead of only on the one its author used. The tokens `TB`, `TD`, `BT`, `LR` and `RL` are accepted as aliases, so a habit picked up from Graphviz or Mermaid does not have to be unlearned. PlantUML has only `top to bottom direction` and `left to right direction` -- `bottom to top direction` and `right to left direction` are syntax errors -- so `up` degrades to `down` and `left` to `right`, with one warning for the whole project rather than one per diagram. Graphviz draws all four with `rankdir`. A diagram is never refused for asking: a plainer diagram beats a failed build. A diagram already drawn the way it asks to be emits *nothing*, so a project that does not use the option keeps byte-identical diagram source. An explicit `:direction:` beats the engine config blob it is written beside, on both engines. The blob is a preamble of defaults and the option a per-element value, which is the precedence the engines already give them -- but a *default* direction has to be restated to win, or the blob's layout would quietly stand. On graphviz that means writing `rankdir` after the whole blob, `graph [...]` block included, since that is where the shipped `lefttoright`/`toptobottom` configs put theirs. A disagreement between the two is reported: it is the one case where the author has plainly said two different things. `needs_flow_direction` is the project default, and is validated where the configuration is read rather than where a diagram is drawn, so a project that misconfigures it and happens to have no needflow is still told -- exactly once, against `conf.py`. Enumerated `needs_flow_*` values are now matched the way the matching option values are: docutils' `choice` lowercases and strips before matching, so `:engine: PlantUML` has always been accepted while the same word in `conf.py` warned and fell back to something else. `needs_flow_engine` gets the same treatment, and its warning now quotes the value as it was written so it can be found in `conf.py`. `ENV_DATA_VERSION` is bumped: the resolved options are stored on the doctree, and an old reader over a new doctree raises `KeyError` instead of re-reading.
… the config The option began as a bare flag meaning "label edges with the outgoing title", which is exactly one of four things an edge label can be -- so it is widened rather than replaced. Written bare it still means `outgoing`, byte for byte, and no existing document changes. Written with a value it takes `none`, `outgoing`, `incoming` or `type`; the last three are new, and the flag could not express any of them. `needs_flow_show_links` takes the same four values, and `True`/`False` keep meaning `outgoing`/`none`. Anything that is neither a string nor a boolean is read for its truth, because that is what a value declared `bool` for years actually did: `1` drew labels and drew them silently, so it still does. Only a *string* is held to the enumeration, since a string is someone naming a value rather than leaning on truthiness. **Breaking:** a string that is not one of the four is now reported and falls back to `none`, where it used to be truthy and draw labels. That is the one input whose behaviour changes. The option now *wins* over the configuration instead of being OR-ed with it. A project that turned labels on previously left no way of drawing one unlabelled diagram; `:show_link_names: none` is that way. The widened value lands beside the shared `show_link_names` flag rather than narrowing it: needgantt and needsequence read the same key of `NeedsFilteredDiagramBaseType` and still take it as a bare flag, so the flag records only that the option was *given* and `show_link_names_value` records what it was given.
The option is widened to take the *key* of a legend configuration. Written bare it draws exactly the legend it always drew -- byte for byte, including the long-standing difference that plantuml lists every configured need type while graphviz lists only the ones it drew. That difference is deliberately left alone, so no existing diagram changes; a named legend is how a project gets the same legend on both engines. The key is a key and never the sections inline. A project names its legends whatever it likes, so a legend called `types` would collide with the section called `types` and need a precedence rule nobody should have to learn. One namespace, no reserved words. `needs_flow_legends` holds the named configurations, with two axes: - `parts` is a **list** of sections -- `types`, `links` or both -- **in the order they are shown**. Order is contract rather than an artefact of how the list was written: a reader scanning two diagrams should find the same section in the same place. Only a list is accepted; a bare string cannot express an order, and a second accepted spelling would have to keep meaning the same thing in every tool that reads this configuration. A malformed value is reported and falls back -- `parts: 5` used to end a build with a traceback, and a bare string iterated character by character. - `placement` is a *preference*, not a demand. Unset it takes **the engine's own default placement**, which is the only honest way to write the rule down: both engines here draw a types legend inside the picture and always have, while an engine with no legend construct at all has only the external table. An engine that cannot honour `internal` substitutes the external table silently -- the two carry identical information and differ only in where they sit, so it is a cosmetic substitution rather than an intent gone unhonoured. The external legend is a docutils table, built once in `_shared.py` rather than twice in each engine's own syntax, so it looks the same on both engines, its text is selectable and searchable, and it can describe link types -- which no in-diagram legend ever could. It lists only what the diagram actually drew, which is the scope rule the two in-image legends disagree about. `needs_flow_show_legend` says *which* legend a diagram gets when it asks for one without naming it -- never *whether*: asking stays with the directive, because a legend describes one picture. Resolution is a chain, not a switch: the option's key, then the project key, then the engine's own legend. A key that names nothing is treated as unset, the rule the rest of this vocabulary follows, so it warns and hands on rather than replacing the next step -- a typo in one directive must not silently cost the project the legend it configured. The two steps warn differently because they are different mistakes: an option key is the directive's own text and is reported there every time, under `needs.needflow`; a project key is one `conf.py` line and is reported once for the whole build under `needs.config`, with no directive location. Both readers of `needs_flow_show_legend` coerce it before matching, because `types: (str,)` makes Sphinx warn about a wrong type and then hand the raw value through: a non-string would otherwise reach `str.strip` and end the build with a traceback. Coerced, it simply names nothing and hands on, exactly as a misspelt key does. The read-time check and the per-diagram chain coerce the same way, so they keep emitting identical text and Sphinx's `once` filter still collapses them onto the one without a directive location. `:class:` is now assigned explicitly to the plantuml figure. docutils already copies the classes of the node being replaced onto its replacement, so this changes nothing today and is not a fix; it states the intent locally, so that returning a legend node alongside the figure cannot come to depend on that copy. Verified: the rendered class attribute is identical with and without it.
…ions The harness merged in #1781 keeps the corpus *format* apart from what this repository can express, and grows the seam one slice at a time. This slice fills in the rows the presentation options make expressible: - `CONFIG_KEYS` gains `direction`, `show_legend` and `link_labels`. The portable `engine_config` is mapped separately and per engine, because Sphinx-Needs keeps the two engines' registries in two configuration values instead of under one engine-keyed roof -- it is split rather than renamed. - `OPTION_NAMES` gains `direction` and `engine_config`; `OPTION_VALUES` lists both enumerations exhaustively, so a value outside them is refused rather than written through to a directive that would report it and draw the fallback. `link_labels: outgoing` stays written as the *bare* flag, because that is the byte-parity target the merged cases pin. - Options whose value is a name rather than an enumeration member -- a legend key, an engine config name -- are written through verbatim; neither is a closed set, so there is no table to keep in step with every project's `conf.py`. - `legends:` builds `needs_flow_legends`, so a case can pin a named legend. - `DEGRADATIONS` gains the three ids this repository can now observe, each mapped to its tier, its warning *subtype* and a pattern. The subtype is part of the mapping rather than assumed: a warning saying the right thing under the wrong subtype cannot be suppressed by the documented `suppress_warnings` entry, so the pair is the contract. - Regeneration now records the observed degradations. It had to: a run that rewrote only the source would silently delete the degradation entries of every case whose point is a degradation, leaving it asserting the fallback source and nothing about the warning that produced it. Eighteen cases are carved from the umbrella branch unchanged -- byte-identical files, byte-identical manifest checksums -- so they pass here *unregenerated*, which is what makes them a cross-check of this carve rather than a snapshot of it. `expect.<engine>.legend` is genuinely exercised for the first time, by the external-placement cases. Three cases are new: the two halves of the legend chain (a project key supplying the name, and a diagram's own key beating it), and the configuration half of `direction`, so that no mapping row is a claim nothing checks. The five cases merged in #1781 do not move: their files and their checksums are untouched, which is the byte-preservation claim of this slice stated as a test. `corpus_version` goes to 2 per the versioning discipline in the README, which is itself unchanged.
`:direction:` gets a section of its own, with the accepted values and aliases, the PlantUML degradation, and the precedence over a layout that the `:config:` it is written beside happens to set. The two widened options are documented as widened rather than replaced: each says in a `versionchanged` what writing it bare still means, so a reader with an existing document can see at a glance that nothing of theirs changes. `needs_flow_direction`, `needs_flow_legends` and `needs_flow_show_legend` are new configuration sections. `needs_flow_legends` documents `parts` as a list whose order is contract -- with the reason a bare string is refused rather than accepted as a second spelling -- and `placement` unset as taking *the engine's own default placement* rather than a fixed value, which is the framing that keeps one contract across tools: an engine with no legend construct at all defaults the other way. `needs_flow_show_links` documents the four values, the boolean spellings, the non-string truthiness that a value declared `bool` for years actually had, and the one input whose behaviour changes. A note records that enumerated `needs_flow_*` values are matched without regard to case or padding, as the matching options always have been. The `beside` legend used by the rendered examples is added to the docs project.
The precedence test asserted only that the option's direction *appeared* in the generated source, which a containment check satisfies wherever it appears. On both engines the later statement is the one that takes effect, so a direction emitted before the config blob would have passed the test and then been overridden by the very blob it was supposed to beat -- and on graphviz that is the likely mistake, because the shipped `lefttoright` config sets its `rankdir` inside a `graph [...]` block rather than at the top level. Both orderings are now asserted. Verified by mutation: moving the emission above the blob turns the test red on each engine, where before it stayed green. The byte-exact `option-conflict-direction` corpus case already caught this on graphviz; the guard now also sits next to the behaviour it describes.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1782 +/- ##
==========================================
+ Coverage 86.87% 90.87% +3.99%
==========================================
Files 56 77 +21
Lines 6532 11585 +5053
==========================================
+ Hits 5675 10528 +4853
- Misses 857 1057 +200
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ubmarco
approved these changes
Aug 20, 2026
This was referenced Aug 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Third slice of the #1770 split (after #1780 and #1781): the presentation options.
It gives
needflowan engine-neutral way to say which way the graph flows, what the edges arelabelled with, and what the legend contains — additive only, zero deprecations: two existing
options are widened rather than replaced, so the spellings in existing documents keep working
and keep producing the same bytes.
Options
:direction:(new) —down(default) /up/right/left, plus theTB/TD/BT/LR/RLtwo-letter forms Graphviz and Mermaid users already know, with a
needs_flow_directionprojectdefault. An explicit option beats an engine-config(
:config:)-derived direction on both engines,and disagreement warns. PlantUML has no bottom-to-top or right-to-left primitive (probed: both are
syntax errors), so those degrade to their axis mate with a single warning per project.
:show_link_names:widened — the flag now takes an optional valuenone/outgoing/incoming/type, and written bare still means exactly what it always meant (outgoing),byte-for-byte.
needs_flow_show_linkslikewise accepts a string as well as a boolean(
True≡outgoing,False≡none). The old OR of flag and config becomes a precedence —only an unset option consults the config — which fixes the interaction that made
"project default on, this one diagram off" impossible to express.
:show_legend:widened — it now takes the name of a legend defined inneeds_flow_legends, or nothing. Deliberately key-only: an inline value set would collide withuser-chosen names and need a precedence rule. Written bare it renders today's in-diagram legend,
byte-for-byte.
Config
needs_flow_legends = {name: {...}}defines legends —parts, an ordered list of thesections to draw (
types,links; a list only, and order is rendered as listed), andplacement(
internal/external), a preference: unset takes the engine's own default placement, which isinternal for both engines here. A legend that includes
linksrenders as a table beside thediagram, since neither engine can draw link rows inside it.
needs_flow_show_legendnames the legend a diagram gets when it asks for one without naming itsown; it selects which, never whether — presence stays per-directive, so there is no off-switch
value for a legend name to collide with.
An undefined key resolves as a chain — the directive's key, else the project's, else the
engine default — warning at each undefined step and handing on, because an unusable value is
treated as unset everywhere in this vocabulary. The two steps warn at different tiers: a bad
directive key per directive (
needs.needflow), a badneeds_flow_show_legendonce per project(
needs.config, no directive location — it is aconf.pymistake). All four new configs arevalidated as they are read, so a project that misconfigures one and happens to have no needflow
is still told; the checks warn and fall back, never crash (including non-string values,
parts: 5,and
parts: "links"). Enumerated config values are matched the way the option parsers alreadymatch theirs — case-insensitively, ignoring surrounding whitespace — and
needs_flow_engine'smembership check from #1780 gets the same treatment.
Nothing moves for existing projects — with one named edge
Verified by cross-commit diff of the generated diagram source on both engines (a five-needflow
probe using only pre-existing spellings, and a ten-shape matrix over every reachable
show_link_namesshape), and independently reproduced in review: projects using only existingspellings produce byte-identical output. No test fixture changed:
git diff master -- tests/doc_test/is empty,.rstandconf.pyalike. The five conformancecases merged in #1781 — including the bare-
:show_legend:and bare-:show_link_names:paritytargets — pass unregenerated, with unchanged checksums.
The one edge, and why the changelog has a Breaking section:
needs_flow_show_linksset to astring was never a supported spelling (the config was declared
bool, and Sphinx alreadywarned about the type) but drew labels via truthiness. It is now read as a value: an unrecognised
string warns and draws no labels, and
'none'— the sharpest case — now silently means what itsauthor meant instead of the opposite. Non-string truthiness (
1,0) is deliberately preserved.Environment version
ENV_DATA_VERSIONgoes 6 → 7: the directive persists the resolved options in doctrees, and anolder reader over a newer doctree fails on the missing keys (reproduced by rebuilding across the
change and watching it raise, then fixed by the bump). No other open PR claims 7.
Conformance corpus
corpus_version1 → 2, 5 → 23 cases: direction (one per value, including the two PlantUMLdegradations and the option-vs-engine-config conflict), the four link-label values plus
config-driven and option-beats-config cases, and six legend cases (explicit keys, order pinning,
the chain). Fifteen case files are byte-identical to the reviewed umbrella branch and pass here
unregenerated — a cross-check of this carve rather than a snapshot of it. The degradation mapping
table gains its first three live rows (tier + subtype + pattern), regeneration now records
degradation entries, and the per-engine
expect.<engine>.legendkey is exercised for the firsttime by the external-placement cases. Regeneration is idempotent (verified over two full runs).
Tests and review
Every behaviour was recorded failing before its implementation landed, and seven targeted
mutations each turn a test red — one of them (an option-beating-
:config:assertion satisfiableby a
rankdiremitted in the wrong place) was caught by the byte-exact corpus case and now has adedicated ordering fence. The adversarial review reproduced the byte-preservation, the matrix, the
tier split, and the ENV crash independently, and found one real defect — a non-string
needs_flow_show_legendcrashed the build — which is fixed at both affected sites with red-firsttests for both paths. Full suite: 1637 passed; the 47 remaining failures/errors are pre-existing
environmental ones, byte-identical to master's set. Docs build warning-count and warning-set
identical to master.
Follow-ups (later slices of the #1770 split)
needs_links[].line/part_line/color/part_color/arrow,needs_types[].shape) with the first deprecations.:styles:+needs_flow_styles,:engine_config:, the remaining deprecations, andmoving the engine membership check into
validate_flow_config(review measured today's 🐛 needflow: fix the graphviz label escaper, and stop bad configuration ending the build #1780behaviour reporting a
conf.pyengine mistake against a directive location, and staying silentwhen no needflow exists — inherited there deliberately).