Repository navigation
✨ sphinx-needs: needs.derive_unresolved for reads of same-pass values - #2080
Merged
Merged
Conversation
Dynamic functions and variants are resolved in one pass, need by need, in
the order the needs reached the environment, so a `[[…]]` or `<<…>>` that
reads a field another one computes sees the computed value or the
unresolved one depending on document names, the documents the last build
re-read, and `-j`. Nothing reports it today.
These tests pin a `needs.derive_unresolved` warning for such a read, once
per reading call, at the reading need: a `copy` of another need's field
and of the need's own, a whole-project and a `links_only` `calc_sum`
(including the need's own computed `links`), `check_linked_values` up to
its early exit, and variant conditions up to the first true expression.
A read is reported when the field carries a `[[…]]`, `<<…>>` or `<{…}>`
after `needextend`, whether or not the pass had reached it: each case is
built in both layouts, and an incremental build that flips the value read
leaves the warning unchanged. A call that fails because of what it read
gets both warnings, and each subtype is suppressed on its own. Authored
values, `links_from_content`, an `ndf` role and a condition name supplied
by `needs_filter_data` are not reported. ubCode's
`dynamic_functions_unresolved` fixture, adapted where sphinx-needs refuses
it, gives ubCode's findings.
All but the four negative tests fail against this commit's code.
A `[[…]]` or `<<…>>` that reads a field which itself carries a `[[…]]`,
`<<…>>` or `<{…}>` is now reported as `needs.derive_unresolved`, once per
reading call, at the reading need's location. The resolution pass writes
each result into its need as it goes, in the order the needs reached the
environment, so such a read sees the computed value or the unresolved one
depending on document names, on what the last build re-read, and on `-j`.
A read is reported when the field read is a key of the read need's dynamic
fields after `needextend` (`NeedItem.carries_dynamic_value`, a read-only
accessor over a set the pass never clears), not when the pass has yet to
reach that need: the second would make the warning flip with the build
history, and a `-W` build go red or green by which file was edited last.
It is also ubCode's rule, so both tools report the same reads.
`resolve_functions` opens a private record (a context variable) around
each dynamic function call and each variant, and reports it once the call
is over, whatever its outcome, so a call that fails because of what it
read also says what it read. The built-ins note their reads into it:
`copy` its source field, `calc_sum` each summand and, with `links_only`,
its own `links`, `check_linked_values` its own `links` and each target it
compares until it stops, and a variant the fields of its own need each
evaluated condition names (not a name `needs_filter_data`, `build_tags` or
`var` supplies). Outside the pass no record is open, so an `ndf` role, a
`:style_row:` and user functions record nothing. A `filter` argument's
reads are not reported.
The message keeps ubCode's words up to the reads and ends with a clause
that holds whether or not the value had been computed yet. The new
subtype is listed with the build warnings, so `suppress_warnings =
["needs.derive_unresolved"]` silences it alone. The documentation of
dynamic functions describes the warning, and the changelog entry goes
under `Unreleased`.
A `filter` is evaluated on values the pass may or may not have computed yet, so a filter that reads a computed field keeps or drops a need by the order the needs are resolved in. `calc_sum` and `check_linked_values` noted a summand or target only after their filter kept it, so a filtered sum over a computed field warned in one build and not in the next: on a two-page project, no warning on a scratch build, one after re-reading the reader's page, none again after re-reading the target's. Both now note the read before the filter: a filtered sum names every need it considers whose value is computed, kept or not, since its result depends on them through the filter anyway. A new test builds that project twice and asserts the same two warnings both times. Two read sites had no test: `check_linked_values` reading the need's own computed `links`, and `copy(filter=)` reading the lowest-id match it copies from rather than the first match in read order. Both have one now. `calc_sum` fetches the call's record once per call (`_open_reads`) rather than once per summand, and skips every note outside the pass. The documentation says what a filter on a computed field still decides (a `copy` with such a filter can be reported in one build and not in another, and the needs a message names can vary after the first computed value a call reads), and that the reads your own functions make themselves are not reported, while a built-in they call during the pass is, under your function's name.
…en builds The caveat in the dynamic-functions docs names check_linked_values beside copy: a check that stops at an authored target its filter kept, before it reaches a computed one, is reported in one build and not in another, as the review measured. The fence test's docstring is limited to its own layout.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #2080 +/- ##
==========================================
+ Coverage 92.39% 92.47% +0.08%
==========================================
Files 136 136
Lines 19320 19394 +74
==========================================
+ Hits 17850 17934 +84
+ Misses 1470 1460 -10
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:
|
This was referenced Oct 6, 2026
chrisjsewell
added a commit
that referenced
this pull request
Oct 6, 2026
…epends on earlier extends (#2083) ## Summary `needextend` directives were applied in `(docname, lineno)` order only, and each filter was evaluated against the needs as the extends applied before it had left them. So which `:status:` won a conflict depended on file names, and renaming a file could change which needs a filter reached: in a project where `b.rst` holds `needextend:: TGT_1 :status: closed` and then `needextend:: status == "closed"`, that filter tags `TGT_1`, while the identical filter in `a.rst` matches nothing. - **`:extend_priority:`**, an integer of 0 or more, default **500**; extends are applied sorted by `(extend_priority, docname, lineno)`, **lower first**. That is Sphinx's own convention for event handlers (`app.connect(event, handler, priority=500)`: lower runs first, default 500), so on a conflicting `:status:` the higher priority is applied last and wins, and `+tags` values append in ascending priority. A project that never sets the option keeps exactly today's order (pinned by a `needs.json` snapshot recorded before the change). - *Why not `priority`*: a `needextend`'s options are field names (the directive has a catch-all option spec), and `priority` is a field projects define (this package's own schema guide and changelog examples declare one), so `:priority:` on a `needextend` would be ambiguous with setting that field. The `extend_` prefix avoids the collision. (The issue proposed `extend_order`; the name follows Sphinx's `priority`.) - *Why not default 0*: with lower-first, a default of 0 leaves no room to run anything before the unmarked extends without negative numbers. 500 leaves room on both sides, and negatives are refused. - The value is parsed with docutils' `nonnegative_int`, as `max_items` is in the view directives. An invalid value (`abc`, `-1`, `1.5`, empty) is handled exactly as a bad `:strict:` is: one `needs.needextend` warning at the directive (`Invalid value for 'extend_priority' option: …`), and nothing recorded, so that extend is not applied at all (not applied with the default). - Like `:strict:`, the option is popped before the options that modify fields, so a field named `extend_priority` (or `strict`) cannot be replaced by a `needextend`; `:+extend_priority:` / `:-extend_priority:` still reach it. Documented on the page, and pinned by a test. - **Matching is unchanged in this release, with a dual evaluation.** Before the first extend is applied, the filters are evaluated against the needs as written (no copy of the needs is needed, as `id` cannot be extended; logging is suppressed for that pass so a filter's own errors are still reported once). That evaluation is done once per filter string and document (every one reads the same unmodified needs, and only `c.this_doc()` depends on the document). Suppressing the warning silences it through Sphinx's own filtering; the evaluation itself is not skipped (a skip keyed on `suppress_warnings` was tried and dropped as not worth a parameter and a hand-rolled check for a one-release notice). The apply loop then evaluates each filter against the live needs, as today, and where the two match sets differ, the extend is reported once, at its location, as the new **`needs.needextend_match_order`**: ``` b.rst:7: WARNING: the needs matched by this needextend depend on modifications applied by earlier needextend directives: it matches 1 need (TGT_1) now and 0 against the needs as written; from the next release filters are evaluated against the needs as written, before any needextend is applied [needs.needextend_match_order] ``` Both sets are named in need-id order (plain string order), the first three ids and a count of the rest. Id-targeted extends are never compared: their target is fixed. **The next release flips matching to the needs as written**, so the reported extends are exactly the ones whose reach will change. `:extend_priority:` orders the modifications; it is not a way to choose what a filter matches. In this release a filter sees the changes of the extends applied before it, so a priority can change its matches, and such an extend is reported; from the next release it never does (the issue comment's "does not affect which needs a filter matches" describes the semantics after the flip, not this release, and the docs, the changelog and the subtype's description now say which is which). - **`ENV_DATA_VERSION` 8 → 9**: `NeedsExtendType` gains `extend_priority`, which changes the pickled environment. Measured: a rebuild over a `_build` written before this change ends with exit 2 (`KeyError: 'extend_priority'`, surfaced from `build_needs_json`) at 8, and re-reads every document and exits 0 at 9. - **Cost**, measured on a generated project (10 000 needs over 20 pages, 100 filter-targeted and 100 id-targeted extends, `-b needs -E`, the subtype not suppressed, two runs each; "without" stubs the as-written evaluation out): - 100 **distinct** filter strings: `extend_needs_data` 6.6 / 6.9 s → 10.9 / 10.7 s (+3.8–4.3 s, +54–65 %; 100 as-written evaluations, 3.6 s), whole build 21.2 / 22.1 s → 25.6 / 25.1 s (+3.0–4.4 s). - 100 copies of **one** filter string: one as-written evaluation (20–22 ms); `extend_needs_data` 3.2 / 3.0 s → 2.8 / 2.7 s and whole build 17.8 / 18.0 s → 17.5 / 17.2 s, i.e. no measurable cost. So the cost is one filter evaluation per distinct filter string and document, for the one release that carries the notice. - **Docs**: the `needextend` page gets an `extend_priority` subsection (the sort key, the direction, the default, an example where a `:status:` set at 600 wins over one set at 400 whatever the file order, the shadowed field) and a "Filters and earlier needextend directives" section (the dual evaluation, the notice for the next release, `suppress_warnings = ["needs.needextend_match_order"]`). Both carry `versionadded:: 9.0.0`, as the pages already do for this release; **release writer: check the number**, and the wording "the next release" in the warning text and the docs. - **Changelog**: `Unreleased` → `Improvements`, marked **(changed output)**: a project building with `-W` that has an order-dependent filter goes red until the filter is rewritten or the subtype is suppressed, with `"needs.needextend_match_order"`: `"needs.needextend"` in `suppress_warnings` does not cover it (Sphinx matches the subtype exactly). Closes #1658; part of #2064 (fifth item). Independent of #2080 and #2081; it touches two sentences of `docs/directives/needextend.rst` and `docs/dynamic_functions.rst` that #2081 also edits ("document-name and line order" → "extend priority, then document-name and line order"), and the changelog `Unreleased` list: whichever lands second resolves those by hand. ## Tests New file `packages/sphinx-needs/tests/test_needextend_priority.py` (inline `test_app` projects, exact `build_warnings(app) == [...]`), committed first and run against the unchanged code: 13 of its 16 items failed, and the 3 guards passed. The review round added a priority-0 extend to T3 and a test for the memo (17 items). | test | what it pins | before the change | |---|---|---| | T1 `test_without_the_option_needs_json_is_unchanged` | no option anywhere: the whole `needs.json` equals the snapshot recorded before the change; no warning | green (guard) | | T2 `test_higher_priority_is_applied_last[serial]` | `a.rst` 600 `late`, `b.rst` 400 `early` → `late` | `'early' == 'late'` | | T3 `test_appends_follow_ascending_priority` | `+tags` at 600, 0, 400 and the default → `p0, p400, p500, p600` (0 runs first, it is not "unset") | `['p600', 'p400', 'p500']` (before the 0 was added) | | T4 `test_equal_priorities_keep_document_order` | an explicit 500 and no option tie, `(docname, lineno)` decides; every recorded extend carries 500 | unknown-option warning | | T5 `test_invalid_priority_is_reported_and_not_applied[abc, -1, 1.5, empty]` | the exact warning, the need untouched, nothing recorded | unknown-option warning, and applied | | T5b `test_priority_option_shadows_a_field_of_that_name` | `:extend_priority: 7` sets the priority; `:+extend_priority:` still appends to the field | `'7' == 'authored appended'` | | T6 `test_filter_depending_on_an_earlier_extend_is_reported[serial]` | the recon project as it is: exactly one warning, at `b.rst:7`; `needs.json` values as today | no warning | | T7 `test_no_warning_where_the_matches_agree` | a filter on a field no extend changes, and id-targeted extends after other extends: no warning | green (guard) | | T7 `test_match_order_warning_is_suppressed_alone[reported, suppressed]` | `suppress_warnings` silences the subtype and not the other `needextend` warning | `[reported]` red; `[suppressed]` green (guard) | | T8 `test_needs_as_written_ignore_an_earlier_priority` | a priority-100 extend in `z.rst` closes REQ_1 to REQ_5 before the two `status` filters at 500 in `a.rst` (which sorts first): both warn, `6 needs (REQ_1, REQ_2, REQ_3 and 3 more) now and 1 (REQ_6)` and `0 needs now and 5 (…)`; the needs are written in reverse id order | unknown-option warning, no match-order warning | | T9 `[j2]` variants of T2 and T6 | identical results under `-j 2` (padded to 7 documents, since Sphinx 7 reads in parallel only above 5) | red, as their serial twins | | `test_identical_filters_are_evaluated_once_per_document` | the recon project plus a second identical filter in `b.rst`: both `b.rst` filters reported at their own lines; the as-written evaluations are exactly `(filter, "a")` and `(filter, "b")` | (review round) red without the memo: three evaluations | Mutation proofs (each applied to the finished code, the new file run, the code restored): | mutation | red | |---|---| | M1 sort key without `extend_priority` | T2 serial and j2, T3, T8 | | M2 the "as written" sets evaluated inside the apply loop (the live needs) | T6 serial and j2, T7 `[reported]`, T8 | | M3 default 0 instead of 500 | T3, T4, T8 | | M4 an invalid value falls back to the default instead of skipping | T5, all four | | `:extend_priority: 0` treated as unset (`nonnegative_int(...) or DEFAULT_EXTEND_PRIORITY`) | T3 (`['p400', 'p0', 'p500', 'p600']`) | | the memo removed (one evaluation per extend) | the once-per-document test only | No existing assertion or snapshot changed: before `master` was merged in, the full suite was 2102 passed / 13 skipped (2084 + 18 new; one of those went with the dropped skip, so the file now holds 17), 305 snapshots passed (304 + the new one); after the merge the needextend test files, `uv run poe lint` and `uv run poe typecheck` pass. `uv run poe docs-needs` was not run (it creates its own environment); the whole docs tree was built with the package's development environment and stand-ins for three docs-only extensions, with no warning (nitpicky included), and the new example renders `late` with `applied_first, applied_second`. ## Follow-ups - **The flip**, in the next release: apply each filter-targeted extend to the needs it matched as written (the sets this change already computes), and retire or reword `needs.needextend_match_order`. One case is not reported now: a filter whose evaluation raises against the needs as written but not live (in practice only a non-bool result on the fast path) is not compared; after the flip it would be an `Invalid filter` warning. - **ubCode** mirrors `:extend_priority:` (same default, direction and sort key) and, if it evaluates filters against the mutated needs too, the same notice. - **`options.popitem()`** records one directive's options in reverse written order. The same key twice is refused by docutils (`duplicate option`), but different keys on the same field are applied in reverse, measured: `:-tags:` then `:+tags: new` gives `[]` (written order would give `['new']`), and `:status: replaced` then `:+status: appended` gives `replaced` (written order: `replaced appended`). It is within one directive, so the priority does not touch it; applying in written order would be a change of output of its own.
…lved branch # Conflicts: # packages/sphinx-needs/docs/changelog.rst
The record of a call's reads of same-pass values was ambient state: a module-level ContextVar the built-ins reached through `_open_reads` and `_note_read`. It is now a keyword-only parameter. `resolve_functions` opens an `UnresolvedReads` for each call and each variant condition and hands it down: `_execute_dynamic_func` passes it as `reads=` to a function marked `@records_reads` (`copy`, `calc_sum`, `check_linked_values`), the mark read off the function as registered, and `_get_variant` notes a condition's names into it. Every other function is called exactly as before, never with `reads`. `execute_func` passes none, so a built-in called by an `ndf` role, a `:style_row:` or a user's own function notes nothing. What is noted is decided in one place, `UnresolvedReads.note_read`. The rule, the message, the subtype and where the warning is reported are unchanged. One consequence is documented: a built-in that a user's function calls during the pass is no longer reported under that function's name, since no record reaches it. A `reads=` written in a call fails that call with the usual `needs.dynamic_function` warning. New tests pin that a user function is called without the record, that a built-in it calls is not reported, that a built-in called after the pass notes nothing, and the reserved keyword.
The `records_reads` mark was `True` in the function's `__dict__`, which `functools.wraps` copies, so a user's function made with `functools.wraps(copy)` was marked too: called with `reads=` in the pass, it failed if it took no `**kwargs` (it resolved before the refactor), and one that forwards its keywords had its read reported under its own name. The mark now names the function it is set on, and `_execute_dynamic_func` checks that it names the function as registered, so a copied mark marks nothing. A test pins both shapes. A user's function calls a built-in inside the pass; it simply has no record to pass on, so the built-in's read is not reported. The docstrings now say so rather than calling it a read after the pass, and say that one record serves a whole `<<…>>` variant, shared by the conditions it evaluates. The reserved `reads` is worded as "do not give it in a call (doing so fails the call)". A test fences the built-ins' `reads` as keyword-only with a `None` default, so a shared default record cannot come back. `_execute_dynamic_func`'s docstring lists its real parameters.
chrisjsewell
pushed a commit
that referenced
this pull request
Oct 7, 2026
The three source and test files that both sides changed take master's versions (the explicit-record refactor and its review fix); the one section that both sides placed, the needs.derive_unresolved subsection, is kept once, under Processing order as this branch intended, with master's final wording.
chrisjsewell
added a commit
that referenced
this pull request
Oct 7, 2026
Stacked on #2080 (its branch is this pull request's base, so the diff is the one docs commit; GitHub retargets it to `master` when #2080 merges). ## Summary The documentation stated one barrier between the stages that run once every document is read: a dynamic function cannot read back links ("Restrictions / incoming_links" in `docs/dynamic_functions.rst`). Everything else about the order was undocumented, and three of its effects surprise users: - a `needextend` filter never sees the result of a `[[…]]` or `<<…>>`: `needextend:: late == "D"` matches nothing although the need's `late: [[copy("title")]]` resolves to `"D"`; - the order of a need's options is irrelevant: with `early` declared before `late` in `needs_fields`, a need that writes `:late: [[copy("title")]]` before `:early: [[copy("late")]]` still computes `early` first, which reads `late` unresolved (`"None"`); - a variant condition sees only the fields of its own need computed before its own field: `:band: <<[early == "E"]:hit, miss>>` gives `miss` and the identical `:after:` gives `hit`, because `band` is declared before `early` and `after` after it. This replaces that paragraph with a **Processing order** section (label `needs_processing_order`) that lists the four steps, each checked against the code and against a probe project built on this branch: 1. `needextend`, applied in (document name, line) order; each filter sees the needs as written plus the earlier extends' changes, never a `[[…]]`/`<<…>>`/`<{…}>` result; an extend may set a field it can modify to a call, evaluated in step 2; 2. `[[…]]`, `<<…>>` and `<{…}>`, need by need, and within a need in the fixed field order whatever the option order, a field a `needextend` turned into a call last; a whole-project `calc_sum` and `copy(filter=…)` take the needs in need-id order; reads of a value computed in this step point to the `needs.derive_unresolved` subsection, which becomes a child of the new section; 3. back links, link conditions, unknown links, so no call can read a back link (the old restriction, kept); 4. constraints; then the needs are frozen, and schema validation, every page and `needs_warnings` at the end of the build see the final values. Also: - `docs/directives/needextend.rst` says that extends are applied in document-name and line order and that a filter sees the earlier extends' changes, linking to the section; its claim that `needextend` "can modify all string-based and list-based options" is corrected: it modifies `status`, `tags`, `style`, `layout`, `hide`, `collapse`, and every extra field and link option whatever its type (a number or boolean field included), and `+option` takes only string and list options; - the tutorial's "Resolve" step links to the section; - changelog: `Unreleased` → Improvements. Part of #2064 (third item); builds on the need-id order PR and the `needs.derive_unresolved` PR, whose semantics it documents. Docs only, no tests. `uv run poe lint`, `uv run poe typecheck` and the two dynamic-function test files pass; the changed pages parse with docutils (Sphinx-only roles and directives stubbed) without warnings or errors, and every `:ref:` they use resolves to a label. `uv run poe docs-needs` was not run locally (its intersphinx inventories need network access); the docs CI job is the authority. ## Follow-up - #2075: `constraints` set by a `needextend` or by a `[[…]]` is silently dropped: `NeedItem.__setitem__` checks that the constraint results are not computed yet and then never stores the value. The `needextend` page therefore does not list `constraints` among the options it modifies.
chrisjsewell
pushed a commit
that referenced
this pull request
Oct 8, 2026
…ut of scope (sphinx-needs) dynamic_functions.rst: the processing order is rewritten for the strata (needextend, link fields, back links, every other field, checks), with what each built-in reads, the order a set and a link list are read in, user functions last, and two new sections, Cycles (needs.derive_cycle) and Reads that cannot be ordered (needs.derive_scope), which replace the one on needs.derive_unresolved; predicates filters are not reported yet. The need.<field> argument form is documented with the rule that a field selecting what a call reads must be final first. needextend.rst: a filter naming a computed field is needs.derive_scope. changelog: the #2080 and #2081 entries become the 9.0.0 statement of the ordered pass, with needs.derive_cycle and needs.derive_scope, need.<field> admitted and a None result adding nothing, and a breaking-change entry says what moves for a project.
5 tasks done
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.
Summary
All
[[…]],<<…>>and<{…}>are resolved in one pass, need by need, in the order the needs reached the buildenvironment, and each result is written into its need as soon as it is computed. A dynamic function or variant
condition that reads a field which itself carries a
[[…]],<<…>>or<{…}>(on another need, or on its own need)therefore reads the computed value or the unresolved one depending on that order: document names, the documents the
last build re-read,
-j. Nothing reported it. It is now a warning,needs.derive_unresolved, once per readingcall, located at the reading need (
Nonefor an external need, as for every pass warning).The rule. A read of
nameon needTis reported iffnameis a key ofT's dynamic fields afterneedextend(new read-only accessor
NeedItem.carries_dynamic_value(name), over a set the pass never clears), whether or not thepass has already computed it. Reporting only "not yet computed" reads would make the warning itself depend on build
history: on one project (
CHAIN_Xona.rstcopiesCHAIN_Y.summaryonb.rst,CHAIN_Y = [[copy("title")]]) ascratch build reads
'None', an incremental build re-readinga.rstreads'done', one re-readingb.rstreads'None'again, so a "not yet visited" warning would turn a-Wbuild red, green, red by which file was edited last.So the warning also fires where today's value happens to be the computed one (
CHAIN_B,CYC_B,VAR_CONDin thecorpus below): that value is an accident of insertion order and flips on an incremental build (pinned by a test).
It is ubCode's rule (
reads_unresolved), so both tools report the same reads.What is read (only the built-ins and variant conditions, phase 0):
copy: the source field: own need,need_id, or thefilter's chosen match (the lowest id);calc_sum: the value of every need it considers (need-id order for a whole-project sum), noted BEFORE itsfilterruns, so a need the filter drops is named too; withlinks_only, also the need's ownlinks;check_linked_values: the need's ownlinks, then each target'ssearch_optionuntil it stops, again notedbefore its
filter;needs_variantslookup, up to the first thatholds), on the need's own fields, excluding names
needs_filter_data,build_tagsorvarsupply (they win over afield of the same name in the evaluation context).
Mechanism.
resolve_functionsopens oneUnresolvedReadsrecord for each dynamic function call and each<<…>>variant it evaluates (the conditions of one variant share it), and passes it down explicitly; no module-levelor context state is involved.
_execute_dynamic_funchands the record, as the keyword-only argumentreads, only toa function carrying the
records_readsmark (the built-inscopy,calc_sumandcheck_linked_values). The marknames the function it was set on and is checked against the function as registered, so a wrapper that copies a
built-in's attributes (
functools.wraps) is not marked. Each built-in notes, throughUnresolvedReads.note_read, afield or link it reads itself that carries a
[[…]],<<…>>or<{…}>of its own once everyneedextendisapplied:
copythe field of the need it copies from (with afilter, the match the filter chose);calc_sumthe field of each candidate need, andcheck_linked_valuesthe field of each target it reaches before it stops, each before theirfilterdecides on it, and their own need'slinkswhere they read them. The fields afilterreads are never noted._get_variantreceives the same recordand notes the need's fields that each evaluated condition names. Every other function, every user-registered one
included, is called exactly as before and never receives
reads. A built-in called after the pass (anndfrole, aneedtable
:style_row:, a directexecute_funccall), or by a user's own function during it, getsreads=Noneandnotes nothing: the user's function has no record to pass on. When the call or variant ends, whatever its outcome,
the record is reported in a
finallyblock as oneneeds.derive_unresolvedwarning located at the reading need.Message: ubCode's words up to the reads, then a clause that is true whether or not the value had been computed:
A name read on four or more needs names the first three (
… and K more); several names are joined as ubCode joinsthem (
a, b and c);carriesis singular only for one name read on one need.Not reported (documented): the fields a
filterargument reads (current_needincluded; ubCode evaluates none ofthese), the reads your own functions make, through a built-in they call or otherwise (no record reaches a user
function),
<{…}>(variant data, not needs),links_from_content(reads the document). Aneed.<attr>argumentcannot occur in a field value in sphinx-needs.
Caveats (documented): a
filterthat reads a computed field still decides, by the order, which needs it keeps.Noting before the filter keeps
calc_sumstable, but acopywith such a filter, and acheck_linked_valueswhosecheck stops at a target the filter kept before it reaches a computed one, can be reported in one build and not in another. After the first computed value a call reads, what it reads next (an early
exit, the first true condition) can depend on that value, so the needs a message names can vary between builds while
the warning itself does not.
Opting out:
suppress_warnings = ["needs.derive_unresolved"]silences exactly this subtype; it is its own subtype(listed in the documentation's build-warnings list), so
needs.dynamic_functionfailures stay visible.Part of #2064 (fourth item); the notice period of #2030 step 0/E. ubCode reports the same reads as the Info-graded
needs.derive_unresolved(useblocks/ubcode#3855): the same finding set on the shared corpus, a lower severity by design.This is changed output: a
-Wbuild with such a read fails until the read is changed or the subtype suppressed.Docs: a "Reads of a value computed in the same pass" section in
docs/dynamic_functions.rst, and a note on thereserved
readskeyword beside the built-ins; changelog entry underUnreleased→ Improvements.For the release writer: the section carries
.. versionadded:: 9.0.0, after the page's existing9.0.0note;renumber it if the release is not 9.0.0.
Tests
packages/sphinx-needs/tests/test_dynamic_functions_unresolved.py(inlinetest_appprojects plus onedoc_testproject; exact
build_warnings(app) == [...]), committed first: 26 red, 4 negatives green, against the unchanged code;30/30 green after. The review round added three (33/33 green): the filtered-sum fence, red against the previous code,
and two read-site tests. The explicit-record round added six more (39/39 green), under T12 below; none of the 33 changed: four with the parameter, two in its review round (a
functools.wrapswrapper of a built-in, and the parameter's keyword-onlyNonedefault).test_message_shape(8) /test_message_needs_a_readtest_chain_across_needsCHAIN_AcopiesCHAIN_B.summary: one warning atCHAIN_A, none atCHAIN_B; target on a page read after ("") and before ("Middle") give the identical list; also under-j 2test_chain_inside_one_needstatus = [[copy("comment")]],comment = [[copy("title")]]:'comment' on need 'SAME_NEED'test_calc_sum_over_every_need'hours' on 2 needs (HRS_1, HRS_2); five:(HRS_1, HRS_2, HRS_3 and 2 more)…links_only_reads_its_own_links/…names_its_computed_targetslinks = [[copy(...)]]:'links' on need 'OWN_LINKS'; linksHRS_2, HRS_3, HRS_1:(HRS_2, HRS_1)in link ordertest_copy_filter_reads_the_match_it_copies_fromcopy(filter=)reports the lowest-id match it copies:RD_ONE(computed match read first, authored lowest id) silent;RD_TWO(authored match first, computed lowest idSRC_A2) warnstest_check_linked_valuesWORK_1.status(already computed: reported anyway); theone_hitgate that stops at an authored target reports nothingtest_check_linked_values_reads_its_own_linkslinks = [[copy("links", "OTHER_9")]]:'links' on need 'GATE_OWN'test_variant_conditionf1declared beforeband(matched) and after (unmatched): identical warning;f1named twice in the variant, named once…reads_only_the_expressions_it_evaluatesneeds_variantsname reads its expression's namestest_a_call_set_by_needextend_is_a_computed_valueLIT_3 :hours: 1+needextend … [[copy("h0")]]:LIT_3is named (total3.0reads the pre-extend value)test_a_failing_call_still_says_what_it_readderive_unresolvedfirst; with the subtype suppressed onlyneeds.dynamic_functionremainslinks_from_contentand an:ndf:of a computed field report nothing; a condition nameneeds_filter_datasupplies is not a field read; the suppressed T1 project is warning-free; the subtype is listed with its descriptiontest_the_warning_does_not_depend_on_the_build_historyindex.rstre-read: value flips""→"Middle", warning list identicaltest_a_filter_on_a_computed_field_does_not_decide_the_warningcalc_sum("hours", "summary == 'done'")and a filteredcheck_linked_valuesoverTGT_F(computedsummaryandhours), built twice: the filter keepsTGT_Fin one build only (total0.0→3.0), and the two warnings are identical in both. Against the previous code, CLI builds gave 0, 2, 0 warnings for scratch, re-read reader page, re-read target pagetest_ubcode_fixturedynamic_functions_unresolvedfixture astests/doc_test/doc_df_unresolved/(hours' default moved out of itsschema;NEED_ATTRdropped): (reader, option, name, needs) set equals ubCode's 12 findings; all 12 message heads are byte-identical to ubCode's…user_function_is_called_without_the_record/…builtin_a_user_function_calls_is_not_reported/…user_wrapper_of_a_builtin_is_not_handed_the_record/…builtin_called_after_the_pass_notes_nothing/test_reads_written_in_a_call_is_an_error/…take_the_record_only_as_a_keyword_defaulting_to_noneneeds_functionsentry with the plain signature (no**kwargs) resolves in the pass with no warning; acopya user function calls readsCHAIN_B's unresolvedsummaryand is not reported;:ndf:roles, a:style_row:and a directexecute_funccall on computed values render the final values and report nothing; afunctools.wraps(copy)wrapper with a narrower signature resolves ('NARROW') and a forwarding one is not reported (the mark names its own function, so a copied__dict__does not carry it);copy("title", reads=1)in a field and in a role each fail that call withneeds.dynamic_function, the build completing;readsis keyword-only with aNonedefault on all three built-insMutation proofs (each applied to the committed code, the new file run, reverted):
-j, T5, T6 f1-first, T6b, T10, T11links_only's ownlinksWarningSubTypesonlypoe typecheck(2 diagnostics); the suppress tests stay green (Sphinx filters on the string)check_linked_values' own-linksnotecopy(filter=)notes the first match (result[0]) instead of the one it copiescalc_sumnotes the summand after its filter againcheck_linked_valuesnotes the target after its filter again@records_readsfromcalc_sum@records_readsfromcopyreads=Nonereads=to every function, marked or notshout() got an unexpected keyword argument 'reads'), and thelinks_from_contentnegative of T9functools.wrapscopies the mark's name, not its identityNonesignature testNo existing assertion or snapshot changed. The full suite was last run whole on master
4e3db3bplus the first commits(2127 passed / 13 skipped, 304 snapshots: 2094 before + the 33 new); after merging master
714123aand theexplicit-record round, the machine at hand had no graphviz
dot, so its 50 render tests failed identically at the baseand at the head while everything else passed with the six new tests added — CI is the authority for the whole-suite count.
Cost, on a 10 000-need project with 100 whole-project
calc_sumcarriers, built with-Eand timing the realresolve_functions. Eight builds each, alternating, on a noisy machine (the base alone ranged 1305–1858 ms):the median is 1836 ms with this change, against 1693 ms before the warning existed (+143 ms, +8.5 %);
the reviewer's session measured +17.6 % on the same point, so the cost is in the +9 % to +18 % range.
The first version of this pull request measured 2028 ms (+19.8 %) in the same session, before
calc_sumfetched the record once per call. The whole-build wall time moves by between ±0.5 % and +2.4 %. The explicit
parameter removes the per-call context-variable set/reset and the per-read lookup, so it is not slower than that.
uv run poe lintanduv run poe typecheckpass.uv run poe docs-needswas not run (itsintersphinx inventories need network access); the new docs section, the reserved-keyword note and the changelog entry
parse without docutils messages, and
docs/conf.pyignores the nitpick on the record's class in the built-ins'autodoc signatures, as it does for
NeedsSphinxConfig.Follow-ups
filterargument (copy,calc_sum,check_linked_values,links_from_content) are not reported(see Caveats): reporting them would make a
copywith a filter on a computed field stable too.schema.default(accepted by ubCode, refused here),
need.<attr>in a field value (accepted by ubCode, need refused here), a linklist mixing a literal and a call (ubCode keeps the literal before resolution, sphinx-needs holds
[]), aneedextend-set call's pre-resolution value (old value here, empty in ubCode), and the defaultneeds_id_regexrefusing
GATE.When squashing, strip the
Co-authored-bytrailer GitHub proposes.