Skip to content

✨ needflow: portable direction, link-label and legend options - #1782

Merged
chrisjsewell merged 6 commits into
masterfrom
claude/needflow-presentation-options
Aug 20, 2026
Merged

chrisjsewell merged 6 commits into
masterfrom
claude/needflow-presentation-options

Conversation

@chrisjsewell

@chrisjsewell chrisjsewell commented Aug 20, 2026 •

Copy link
Copy Markdown
Member

Third slice of the #1770 split (after #1780 and #1781): the presentation options.
It gives needflow an engine-neutral way to say which way the graph flows, what the edges are
labelled 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 the TB/TD/BT/LR/RL
    two-letter forms Graphviz and Mermaid users already know, with a needs_flow_direction project
    default. 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 value none / outgoing /
    incoming / type, and written bare still means exactly what it always meant (outgoing),
    byte-for-byte. needs_flow_show_links likewise 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 in
    needs_flow_legends, or nothing. Deliberately key-only: an inline value set would collide with
    user-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 the
sections to draw (types, links; a list only, and order is rendered as listed), and placement
(internal / external), a preference: unset takes the engine's own default placement, which is
internal for both engines here. A legend that includes links renders as a table beside the
diagram, since neither engine can draw link rows inside it.

needs_flow_show_legend names the legend a diagram gets when it asks for one without naming its
own; 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 bad needs_flow_show_legend once per project
(needs.config, no directive location — it is a conf.py mistake). All four new configs are
validated 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 already
match theirs — case-insensitively, ignoring surrounding whitespace — and needs_flow_engine's
membership 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_names shape), and independently reproduced in review: projects using only existing
spellings produce byte-identical output. No test fixture changed:
git diff master -- tests/doc_test/ is empty, .rst and conf.py alike. The five conformance
cases merged in #1781 — including the bare-:show_legend: and bare-:show_link_names: parity
targets — pass unregenerated, with unchanged checksums.

The one edge, and why the changelog has a Breaking section: needs_flow_show_links set to a
string was never a supported spelling (the config was declared bool, and Sphinx already
warned 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 its
author meant instead of the opposite. Non-string truthiness (1, 0) is deliberately preserved.

Environment version

ENV_DATA_VERSION goes 6 → 7: the directive persists the resolved options in doctrees, and an
older 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_version 1 → 2, 5 → 23 cases: direction (one per value, including the two PlantUML
degradations 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>.legend key is exercised for the first
time 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 satisfiable
by a rankdir emitted in the wrong place) was caught by the byte-exact corpus case and now has a
dedicated 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_legend crashed the build — which is fixed at both affected sites with red-first
tests 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)

  • Slice 4: link and type styling (needs_links[].line/part_line/color/part_color/arrow,
    needs_types[].shape) with the first deprecations.
  • Slice 5: :styles: + needs_flow_styles, :engine_config:, the remaining deprecations, and
    moving 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 #1780
    behaviour reporting a conf.py engine mistake against a directive location, and staying silent
    when no needflow exists — inherited there deliberately).

Chris Sewell 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

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.47328% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.87%. Comparing base (4e10030) to head (ca6d30a).
⚠️ Report is 330 commits behind head on master.

Files with missing lines Patch % Lines
sphinx_needs/directives/needflow/_options.py 97.94% 3 Missing ⚠️
sphinx_needs/directives/needflow/_shared.py 97.72% 1 Missing ⚠️
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     
Flag Coverage Δ
pytests 90.87% <98.47%> (+3.99%) ⬆️

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.

@chrisjsewell
chrisjsewell merged commit 88a32f6 into master Aug 20, 2026
25 checks passed
@chrisjsewell
chrisjsewell deleted the claude/needflow-presentation-options branch August 20, 2026 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants