Skip to content

Replace string nodeids with a structured NodeId internally - #14758

Open
nicoddemus wants to merge 35 commits into
pytest-dev:mainfrom
nicoddemus:refactor-node-ids
Open

Replace string nodeids with a structured NodeId internally#14758
nicoddemus wants to merge 35 commits into
pytest-dev:mainfrom
nicoddemus:refactor-node-ids

Conversation

@nicoddemus

Copy link
Copy Markdown
Member

pytest identified every collection-tree node with a plain "::"-joined nodeid string, repeatedly re-parsed (split/partition) at dozens of call sites for cache persistence, terminal/JUnit reporting, and stepwise/ subtests bookkeeping. Introduce a NodeId dataclass (src/_pytest/_nodeid.py) and use it internally as the dict/set key and equality type everywhere a nodeid was previously compared or looked up as a string, while keeping the existing nodeid: str property as a full backward-compatible surface for external plugins.

Function's NodeId also carries structured per-parametrize-call data (ParamId: id/argnames/scope) when built from live collection data, laying groundwork for future scope-aware scheduling (e.g. in pytest-xdist) without committing any consumer to it yet.

@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Jul 22, 2026
Comment thread src/_pytest/_nodeid.py Outdated
Comment thread src/_pytest/_nodeid.py Outdated
Comment thread src/_pytest/_nodeid.py Outdated
Comment thread src/_pytest/nodeid.py Outdated
Comment thread src/_pytest/cacheprovider.py Outdated
Comment thread src/_pytest/cacheprovider.py Outdated

@RonnyPfannschmidt RonnyPfannschmidt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is shaping up nicely

we'll have to investigate how to deal with the loadscope groups xdist tucks into node ids at the moment as thats a mess that messes with the strings

Comment thread src/_pytest/_nodeid.py Outdated
Comment thread src/_pytest/_nodeid.py Outdated
Comment thread src/_pytest/_nodeid.py Outdated
Comment thread src/_pytest/_nodeid.py Outdated
Comment thread src/_pytest/nodes.py Outdated
@nicoddemus

Copy link
Copy Markdown
Member Author

Should we expose the new node id types as public? I wonder if people will need at least for type annotations...

@The-Compiler The-Compiler left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice cleanup!

Comment thread src/_pytest/nodeid.py Outdated
Comment thread src/_pytest/_nodeid.py Outdated
Comment thread src/_pytest/nodeid.py
Comment thread src/_pytest/nodeid.py Outdated
Comment thread src/_pytest/_nodeid.py Outdated
Comment thread src/_pytest/_nodeid.py Outdated
Comment thread src/_pytest/nodeid.py Outdated
Comment thread src/_pytest/nodeid.py Outdated
Comment thread src/_pytest/nodes.py
Comment thread testing/test_nodeid.py Outdated

@bluetech bluetech left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting work!

pytest identified every collection-tree node with a plain "::"-joined nodeid string, repeatedly re-parsed (split/partition) at dozens of call sites for cache persistence, terminal/JUnit reporting, and stepwise/ subtests bookkeeping.

Is the rationale here performance? If so, is there a way to show it is indeed faster?

Function's NodeId also carries structured per-parametrize-call data (ParamId: id/argnames/scope) when built from live collection data, laying groundwork for future scope-aware scheduling (e.g. in pytest-xdist) without committing any consumer to it yet.

I don't understand what this means, can you expand a bit? I think this explains why ParamId contains argnames and scope, which is otherwise unexpected since they're not present in the string nodeid and are conceptually redundant, so I'd like to understand it better.

Comment thread src/_pytest/nodeid.py Outdated
Comment thread src/_pytest/nodeid.py Outdated
Comment thread src/_pytest/nodeid.py Outdated
Comment thread src/_pytest/nodeid.py Outdated
Comment thread src/_pytest/nodeid.py Outdated
Comment thread src/_pytest/nodes.py Outdated
Comment thread src/_pytest/nodes.py Outdated
Comment thread src/_pytest/reports.py Outdated
Comment thread src/_pytest/reports.py Outdated
def nodeid(self) -> str:
return str(self._id)

@nodeid.setter

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm is there code that mutates report.nodeid?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not internally, but there might plugins out there that do it, so I decided to just implement a wrapper and convert to a OpaqueNodeId internally.

Alternatively, we can use the setter to raise an error instead.

Comment thread src/_pytest/reports.py Outdated
@nicoddemus

Copy link
Copy Markdown
Member Author

Is the rationale here performance? If so, is there a way to show it is indeed faster?

No, AFAIU we always found node ids to be a bit hacky, because while the original idea was to produce an opaque unique id for test items, in the end we always end up parsing it to extract information (such as the path to the file relative to the root), so it makes sense architecturally to use a data structure for it, with the information plainly available.

I don't understand what this means, can you expand a bit? I think this explains why ParamId contains argnames and scope, which is otherwise unexpected since they're not present in the string nodeid and are conceptually redundant, so I'd like to understand it better.

@RonnyPfannschmidt might elaborate a bit given he asked to add this information, but I assume is that given we can now have a richer data structure, we might as well store more information on it.

@RonnyPfannschmidt

Copy link
Copy Markdown
Member

there are about 3 key details that always created a headache

a) we split node-ids all over the place in absolutely hackish ways - this has been a recurring source of grave errors
b) in mutliple venues of communications we ended up with nodeids that are broken in some ways (one of the msot daunting examples being loadscope groups tacking strings on front of nodeids which in turn breaks consistent reporting
c) parameters in nodeids where always completely lossy - now we have a bare minimum of metadata to make sense of/order nodes

and then there's a number of other issues relating to that - so this really is about removing a mess and pain

@nicoddemus

Copy link
Copy Markdown
Member Author

@RonnyPfannschmidt @bluetech @The-Compiler please take another look. 👍

@bluetech bluetech left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update.

Some more comments/replies:


@nicoddemus said:

No, AFAIU we always found node ids to be a bit hacky, because while the original idea was to produce an opaque unique id for test items, in the end we always end up parsing it to extract information (such as the path to the file relative to the root), so it makes sense architecturally to use a data structure for it, with the information plainly available.

Sure. However, I think the aim should be to reduce use of node IDs to a minimum in favor of using Node directly. For example we did it recently with the matchfactories change. That's better because the Node is the ultimate source of truth. That doesn't necessarily conflict with this work, but it may affect the consideration of the tradeoff between extra complexity and benefit.


@RonnyPfannschmidt said:

a) we split node-ids all over the place in absolutely hackish ways - this has been a recurring source of grave errors

BTW regarding this, if I search for "::" under src/ I see several cases of parsing remaining. It would be good to convert them in this PR so we can better evaluate the benefit.


@RonnyPfannschmidt said:

b) in mutliple venues of communications we ended up with nodeids that are broken in some ways (one of the msot daunting examples being loadscope groups tacking strings on front of nodeids which in turn breaks consistent reporting

Do you have a reference for this? I'd like to understand this better, and how structured node IDs would help.


@nicoddemus said:

@RonnyPfannschmidt might elaborate a bit given he asked to add this information, but I assume is that given we can now have a richer data structure, we might as well store more information on it.

@RonnyPfannschmidt said:

c) parameters in nodeids where always completely lossy - now we have a bare minimum of metadata to make sense of/order nodes

Is this referring to use of nodeids intra-process or inter-process (serialized)?

If it's intra-process, as discussed above, can we replace the use of nodeid with Node?

If inter-process, is the idea to enrich the serialization from string to object?


Regarding OpaqueNodeId:

I was thinking whether there's any reason not to make it a typing.NewType instead of the wrapper type. That breaks down into whether there's a need to access path and rest and not just the __str__. I see there is one use of path in cacheprovider. And that makes me think, maybe it's better to change the cacheprovider serialization from string nodeid to structured item/collector node ID, then this one use of opaque is eliminated.

Furthermore, it just generally seems our aim should be to eliminate use of opaque in general, so I looked at a few of the uses.

In stepwise the situation seems similar to cacheprovider. If we serialize the structured node ID then I think we can avoid opaque here.

In junitxml I see a use of as_opaque, but I don't really follow why it's needed. If I comment out self.id = id in _NodeReporter.__init__ all junitxml tests still pass, so it looks like it's unused.

In terminal there are a lot of uses. I think the root cause of this is the use of string node ID in the pytest_runtest_logstart and pytest_runtest_logfinish hooks. Which brings up the point of how we can avoid losing the structured context here.

Comment thread src/_pytest/terminal.py
if test_cases_verbosity < 0:
if test_cases_verbosity < -1:
counts = Counter(item.nodeid.split("::", 1)[0] for item in items)
counts = Counter(item.id.path for item in items)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can use item.path.relative_to(item.config.rootpath) here instead, thus eliminating one nodeid use?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im of the impression that the file collector id should be used there instead of a path

@RonnyPfannschmidt

Copy link
Copy Markdown
Member

Opus found a conflict between this and the FunctionDefinition collection node work (#14769, follow-up #14805): Function.__init__ here drops the nodeid parameter, which that work relies on to keep item ids flat when a definition node is interposed. git merge-tree confirms the branches conflict in python.py.

The underlying point: leaf creation should not add a name. An item isn't a new name segment, it's a call with parameters registered against an already-named collector node — the function definition. So leaf takes only the params and terminalizes the collector's own id:

def leaf(self, params: tuple[ParamId, ...]) -> ItemNodeId:
    return ItemNodeId(path=self.path, names=self.names, params=params)

leaf(name, params) only works today because the flat layout has items contributing their own name segment. With a definition node the name gets added twice:

definition node      : test_x.py::test_a
definition.leaf(...) : test_x.py::test_a::test_a[1]   # current signature
definition.leaf(...) : test_x.py::test_a[1]           # params-only, == the flat form

so the flat item ids fall out for free and the explicit-nodeid plumbing goes away. Two production callers of leaf() on this branch (nodes.py:217, python.py:1716).

nicoddemus and others added 3 commits August 5, 2026 10:49
pytest identified every collection-tree node with a plain "::"-joined
nodeid string, repeatedly re-parsed (split/partition) at dozens of call
sites for cache persistence, terminal/JUnit reporting, and stepwise/
subtests bookkeeping. Introduce a NodeId dataclass (src/_pytest/_nodeid.py)
and use it internally as the dict/set key and equality type everywhere a
nodeid was previously compared or looked up as a string, while keeping the
existing nodeid: str property as a full backward-compatible surface for
external plugins.

Function's NodeId also carries structured per-parametrize-call data
(ParamId: id/argnames/scope) when built from live collection data, laying
groundwork for future scope-aware scheduling (e.g. in pytest-xdist)
without committing any consumer to it yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the module-level parse_nodeid_path_and_names() and reports.py's
private _coerce_node_id() with NodeId.parse()/NodeId.coerce() classmethods,
and reword the Node.id/report.id docstrings to reference nodeid directly
instead of the ad hoc "collection tree address" phrasing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
nicoddemus and others added 25 commits August 5, 2026 10:49
nitpick_ignore the intentionally-undocumented internal NodeId, OpaqueNodeId
and _WithNodeId classes (same pattern already used for BaseReport etc.),
and reword _WithNodeId.id's docstring to avoid an unresolvable :attr:
cross-reference to `nodeid` in the TestReport/CollectReport doc context.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…m boundary

PR review feedback (RonnyPfannschmidt) noted that NodeId.child() had no
protection against being called on an id that already carries params --
i.e. building further tree structure on top of a parametrized leaf. Rather
than a runtime assert, encode the distinction as a type: pytest's own Node
hierarchy already splits cleanly into Collector (can have children) and
Item (leaves), so map NodeId onto that exact boundary.

- CollectionNodeId (path, names): has .child()/.leaf(), for Collector ids.
- ItemNodeId (path, names, params): no .child()/.leaf() at all -- building
  further structure on a leaf is now a mypy error, not a runtime mistake.
- NodeId = CollectionNodeId | ItemNodeId, a type alias for genuinely-mixed
  cases.

Every container previously typed NodeId | OpaqueNodeId was re-derived from
scratch by tracing what hook data actually flows into it (not carried over
blindly): most narrow to ItemNodeId-only (NFPlugin.cached_nodeids,
StepwiseCacheInfo.last_failed, terminal's per-item progress tracking,
subtests' failed-count map), CollectReport/TestReport now carry
CollectionNodeId/ItemNodeId respectively (with covariant Collector.id/
Item.id overrides to match), while LFPlugin.lastfailed, junitxml's
node_reporters and terminal's timing tracker stay genuinely mixed since
pytest_collectreport feeds collector-level failures into the same
structures pytest_runtest_logreport feeds item-level ones into.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Addresses PR review feedback: since .parse() already has the full nodeid
string in hand, cache it directly as _str instead of splitting into
path/rest and letting __str__ reconstruct it later. Direct construction
(bypassing .parse()) still reconstructs via _build_str() as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…le lookup type

Addresses PR review feedback: cacheprovider.py's lastfailed/cached_nodeids
don't care about the rich structure CollectionNodeId/ItemNodeId carry, only
about identity -- normalizing everything to OpaqueNodeId avoids mixing
lookup types in the same container.

- CollectionNodeId/ItemNodeId get a trivial .to_opaque() -> OpaqueNodeId
  method.
- New to_opaque_node_id() free function normalizes a NodeId | OpaqueNodeId
  value (e.g. a report's .id, which may be live or reconstructed from
  JSON) down to OpaqueNodeId unconditionally.
- LFPlugin.lastfailed: dict[OpaqueNodeId, bool], NFPlugin.cached_nodeids:
  set[OpaqueNodeId] -- both narrowed from the NodeId | OpaqueNodeId /
  ItemNodeId | OpaqueNodeId unions, with .to_opaque()/to_opaque_node_id()
  conversions at every insertion/lookup site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t.py

typing.override was added in Python 3.12; pytest supports >=3.10, so add a
version-gated compat shim (mirroring the existing `deprecated` pattern in
_pytest.compat) rather than duplicating it in _nodeid.py, which stays a
leaf module since compat.py has no _pytest.* imports of its own either.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
OpaqueNodeId.as_opaque() returns self, letting callers holding a
NodeId | OpaqueNodeId value call .as_opaque() unconditionally without
checking which concrete type they have. This replaces the standalone
to_opaque_node_id() free function, which is no longer needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds an assert_never arm for exhaustiveness, matching the pattern
already used elsewhere in the codebase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The module is already under the _pytest private package, so an extra
leading underscore on the module name itself is redundant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Upstream renamed CallSpec2 to CallSpec (pytest-dev#14742).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the _CachedStrEqHashMixin with an ABC (_CachedStrEqHash), and
turns the object.__setattr__-based _str caching trick into a proper
init=False dataclass field (renamed to _str_cache) on each concrete
class, so slots=True gives it a real slot. The ABC declares its own
__slots__ = () explicitly, since abc.ABC's __slots__ = () does not
propagate to subclasses that don't redeclare it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…NodeId

These three classes no longer share a base class and no longer compare
equal to each other just because their str() forms coincide -- an
OpaqueNodeId reconstructed from an untrusted external string (an
on-disk cache file, xdist JSON, ...) should never be silently treated
as "the same node" as a live, trustworthy CollectionNodeId/ItemNodeId.
Same-type equality/hashing is now handled by each dataclass's own
generated __eq__/__hash__ instead of a shared, string-based
implementation; str() caching is reimplemented independently on each
class.

Traced every place that could rely on the old cross-type behavior at
runtime and fixed each real dependency by normalizing explicitly via
.as_opaque() before comparing/storing, mirroring the pattern already
used by cacheprovider.py's LFPlugin/NFPlugin:

- stepwise.py: StepwiseCacheInfo.last_failed is compared against a live
  item.id/report.id after being loaded from the on-disk cache as an
  OpaqueNodeId -- this is the entire mechanism --stepwise uses to find
  where to resume.
- terminal.py: _progress_nodeids_reported/_timing_nodeids_reported and
  the teardown-section lookup helpers process every report in a run,
  including SubtestReport, whose .id is always OpaqueNodeId (built via
  a JSON round-trip even in-process), so they'd otherwise double-count
  a subtest and its enclosing test as different nodes.
- junitxml.py: LogXML.node_reporters has the same SubtestReport-vs-
  enclosing-test issue -- without normalizing, a failed subtest and its
  enclosing test's failure ended up as two separate <testcase> elements
  in the JUnit XML instead of one (verified by hand before/after).

testing/test_reports.py's JSON round-trip test is updated to compare
the reconstructed report's _id by string instead of by equality, since
a deserialized report's _id is now correctly a different (less
trusted) type than the original live report's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Session and FSCollector, the only two real callers, both build a
nodeid that is always effectively a bare CollectionNodeId (Session's
literal "", FSCollector's filesystem-relative path -- neither ever
contains "::"), so both can build CollectionNodeId directly instead of
going through Node.__init__'s string-splitting fallback.

This also closes a latent type-safety gap: Item.__init__ already
narrows its own signature to ItemNodeId | None to prevent an Item
ending up with a non-ItemNodeId _id, but that was only a static
restriction -- the shared Node.__init__ body didn't itself guard
against a caller ignoring the type hint and passing a raw str through
an Item subclass, which would have silently produced a CollectionNodeId
in an Item's _id slot. Removing str support from Node.__init__ entirely
closes this structurally rather than by convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Guards against external plugins that may still be passing a raw
nodeid string (now unsupported), raising a clear error pointing at
Node.from_parent() instead of silently misbehaving.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Node hashed by nodeid but never overrode __eq__ (stays identity-based),
so the custom hash provided no actual behavioral difference for any
dict/set correctness -- collisions are always disambiguated via ==,
which is identity-based here regardless of hash value. Every real
dict/set of Node/Item/Collector objects in the codebase already relies
on identity semantics (e.g. main.py's _collect_one_node explicitly
notes fixture registration is "keyed by node identity"). Removing it
lets Node fall back to the default identity-based object.__hash__,
which is the behaviorally-correct pairing for its default
identity-based __eq__.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_WithNodeId was only ever used as BaseReport's sole base and had no
other consumers left (isinstance(x, _WithNodeId) checks were already
removed), so there was no reason to keep it as a separate mixin class
-- its nodeid/id properties now live directly on BaseReport.

Also rewrites junitxml.LogXML.node_reporter's initial if/else as an
explicit match statement with a final assert_never arm for
exhaustiveness, and removes the now-stale _WithNodeId nitpick_ignore
entry from doc/en/conf.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Eight call sites that extracted the path portion of a nodeid via
split("::", ...)[0] / find("::") now use OpaqueNodeId.parse(s).path
instead, matching the pattern already used in cacheprovider/stepwise/
subtests/reports for the same operation.

- reports.BaseReport.fspath: _id.path directly (no parse needed; _id
  already holds the structured form)
- config._do_configure anchor stripping: find/if/slice -> .parse().path
- config.cwd_relative_nodeid: split/join -> .parse()/.path/.rest
- config/findpaths.get_file_part_from_node_id: split -> .parse().path
- terminal.write_fspath_result: split -> .parse().path
- terminal._locationline verbosity check: split -> .parse().path
- terminal warnings Counter: split -> .parse().path
- terminal._get_node_id_with_markup: split/join -> .parse()/.path/.rest

Sites that need the interior :: names decomposed (junitxml
mangle_test_address, main.py resolve_collection_argument, pytester
matchreport) are intentionally left as-is: OpaqueNodeId deliberately
keeps everything after the first "::" opaque and unsplit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ress/matchreport

OpaqueNodeId previously kept everything after the first "::" as one opaque
`rest` field, because the "-"-separated ParamId call boundaries inside the
bracket can't be reliably recovered.  But the two other decompositions ARE
reliable and lossless: the "::" name segments (names can't contain "::",
"[", or "]"), and peeling the whole "[...]" as one raw params string.

Replace the `rest` field with `names: tuple[str, ...]` and
`params: str | None`, and keep `rest` as a derived property (so the two
existing .rest consumers in terminal.py / config/__init__.py need no
change).  The parse order peels the bracket FIRST so a "::" inside a
bracket (issue pytest-dev#469, e.g. test_func[double::colon]) is never mistaken for
a name separator.

With this extension, two call sites that needed individual name segments can
now use the structured type:

- junitxml.mangle_test_address: drop the hand-rolled partition("[") /
  split("::") in favour of OpaqueNodeId.parse(); output is identical for
  all well-formed nodeids, and the double::colon-in-params case (issue
  pytest-dev#469) is handled correctly at the type level rather than by coincidence.

- pytester.matchreport: replace rep.nodeid.split("::") membership test with
  (rep.id.path, *rep.id.names), which also matches parametrized tests by
  their base name (e.g. matchreport("test_func") now matches
  "test_func[x]") and handles ::-in-params correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Confirmed that ParamId.argnames/.scope are written once (python.py) and
read nowhere; the only field ever consumed from a ParamId is .id
("-".join(p.id ...)). With argnames/scope removed, ParamId reduces to a
plain string, so CallSpec._idlist becomes Sequence[str] and
Function.__init__ passes callspec.id (the already-joined bracket string)
instead of callspec.param_ids.

With ItemNodeId.params now str | None (flat bracket string), it is
structurally identical to OpaqueNodeId, so OpaqueNodeId is removed:
ItemNodeId now also serves as the external-string boundary type (via
ItemNodeId.parse, moved from OpaqueNodeId.parse). CollectionNodeId gains
a symmetric CollectionNodeId.parse for CollectReport reconstruction.

as_opaque() → as_leaf(): the ~27 .as_opaque() calls that normalised live
ids and external ids to a single OpaqueNodeId key type are replaced by
.as_leaf() on the genuinely mixed containers (LFPlugin.lastfailed,
junitxml node_reporters, terminal teardown/progress sets). Item-only
containers (NFPlugin.cached_nodeids, stepwise.last_failed,
subtests.failed_subtests_key, terminal progress/timing sets) drop the
normalisation entirely and use .id directly, since both live and
deserialized items are now the same ItemNodeId type.

TestReport._id: ItemNodeId (was ItemNodeId | OpaqueNodeId)
CollectReport._id: CollectionNodeId (was CollectionNodeId | OpaqueNodeId)
BaseReport._id: NodeId (was NodeId | OpaqueNodeId)

The public nodeid: str attribute, fspath: str, and the JSON wire format
are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CollectionNodeId's only job was to enforce at the type level that
child()/leaf() can't be called on a parameterised node. After the
previous simplifications (ParamId dropped, OpaqueNodeId merged into
ItemNodeId), that type distinction had become the source of a new
ceremony: because Collector.id returned CollectionNodeId and Item.id
returned ItemNodeId, any code holding a NodeId union had to normalise
through as_leaf() before comparing or storing. That drove 16 as_leaf()
call sites across cacheprovider (x5), junitxml (x6) and terminal (x5).

Replace both CollectionNodeId and ItemNodeId with a single NodeId
dataclass. child()/leaf() are moved onto NodeId itself with a runtime
ValueError guard if self.params is not None (i.e. the id already
belongs to a leaf), which gives the same safety guarantee without the
static-typing overhead. The two error paths are exercised by new tests.

With a uniform NodeId type the as_leaf() ceremony disappears entirely:
live collection ids and external-string (cache/xdist/subtest) ids are
the same type and compare equal directly.

NodeId = CollectionNodeId | ItemNodeId (the old union alias) is gone;
NodeId is now the single concrete class. All consumers updated;
doc/en/conf.py nitpick_ignore cleaned up; changelog entry revised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mypy 2.3+ reports unused type: ignore comments as errors. The
diamond-inheritance `class SoWrong(nodes.Item, nodes.File)` comment
was no longer needed after upstream changes made this not an error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
leaf(name, params) conflated two operations that NodeId already separates
elsewhere: child() appends a name segment, with_params() attaches params
(terminalizing the id). Separating them makes the API self-documenting:

    parent.child("test_a").with_params("1-x")  # → test_x.py::test_a[1-x]

More importantly, this enables the FunctionDefinition collection node work
(pytest-dev#14769 / pytest-dev#14805) to be merged without conflict: a FunctionDefinition
collector already carries the function name in its names tuple, so
Function.__init__ can simply call definition.id.with_params(params) — no
name added, correct id produced:

    definition node           test_x.py::test_a
    definition.with_params()  test_x.py::test_a[1]   ← correct
    old leaf(name, p)         test_x.py::test_a::test_a[1]  ← name doubled

The flat layout (no FunctionDefinition) is identical in outcome because
child(name).with_params(params) produces the same string as the old
leaf(name, params).

Production callers updated: nodes.py (generic Item) and python.py (Function).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nicoddemus

Copy link
Copy Markdown
Member Author

@bluetech

However, I think the aim should be to reduce use of node IDs to a minimum in favor of using Node directly. For example we did it recently with the matchfactories change. That's better because the Node is the ultimate source of truth. That doesn't necessarily conflict with this work, but it may affect the consideration of the tradeoff between extra complexity and benefit.

I agree. However the concept of "node id" is used in a lot of places in the codebase and externally, so having a proper structure for it seems to be complementary.

BTW regarding this, if I search for "::" under src/ I see several cases of parsing remaining. It would be good to convert them in this PR so we can better evaluate the benefit.

Done.

Some cases didn't fit the original OpaqueNodeId, for example resolve_collection_argument wants the internal names up to the parametrization part ([). So I changed OpaqueNodeId from path/rest to path/names/params, with params being str|None with the contents of the inside of [] if any.

This made me realize that perhaps the introduction of ParamId was a bit hasty/overengineering. If we remove ParamId, then ItemNodeId can be simplified to path/names/params with params being str|None, removing the need for a separate OpaqueNodeId. This also simplifies things considerably given we don't need as_opaque() anymore. I made this change in a separate commit so we can evalute it.

Further: CollectionNodeId was introduced in order to avoid adding a runtime check in cases where we add a new child item to a leaf node. However having separate types makes code that needs to handle both more complex, so decided to also back that off, so ended up having a single NodeId class which parses the traditional string nodeid into a path/names/params structure, which is more inline with the original intent of the PR. Also in a separate commit.


@RonnyPfannschmidt

The underlying point: leaf creation should not add a name.

Done, and decided to rename leaf to with_params because I believe that's clearer.

nicoddemus and others added 2 commits August 5, 2026 11:39
The invariant that Node.parent is always a Collector (or None at the
session root) was previously asserted at runtime with an explicit
isinstance check so mypy could narrow self.parent.id. Encoding it
directly in the type annotation makes the invariant declarative and
removes the need for the assert + the narrowing comment, letting
Node.__init__ read self.parent.id directly.

Node.from_parent and FSCollector.__init__ are updated to match.
No behaviour changes -- the type change is stricter for static
analysis but the runtime set of valid values is identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NodeId's shape is now stable after the full simplification (single
NodeId class, params as str | None). The "Experimental/internal: the
shape may change" warnings on Node.id, Collector.id, Item.id,
BaseReport.id, TestReport.id and CollectReport.id are no longer
warranted. The :meta private: directives are kept as the .id API is
still an internal implementation detail not intended for general
plugin use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/_pytest/junitxml.py
def node_reporter(self, report: BaseReport | NodeId | str) -> _NodeReporter:
match report:
case NodeId() | str():
node_id = coerce_node_id(report)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the pedantic in me wants to see those 2 cases separated

Comment thread src/_pytest/terminal.py

def write_fspath_result(self, nodeid: str, res: str, **markup: bool) -> None:
fspath = self.config.rootpath / nodeid.split("::", maxsplit=1)[0]
fspath = self.config.rootpath / NodeId.parse(nodeid).path

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for write_fspath_result - perhaps we want a non str nodid

Comment thread src/_pytest/terminal.py
if test_cases_verbosity < 0:
if test_cases_verbosity < -1:
counts = Counter(item.nodeid.split("::", 1)[0] for item in items)
counts = Counter(item.id.path for item in items)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im of the impression that the file collector id should be used there instead of a path

Comment thread src/_pytest/terminal.py

counts_by_filename = Counter(
str(loc).split("::", 1)[0] for loc in locations
NodeId.parse(str(loc)).path for loc in locations

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should investigate directly using nodeids in some way here - but this is also a potential followup idea to limit scope between introducing the concept and permeating it trough all of the involved potential usage points

Comment thread src/_pytest/nodeid.py
)

@classmethod
def parse(cls, nodeid: str) -> NodeId:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i still dislike not having a clear way to distinguish between know correctly made nodeids and opaque node ids

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, after reading how it was removed and the exact commit message of the commit, i think we should bring it back - its not surprising it was not used - its freshly introduced

@nicoddemus nicoddemus Aug 5, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

distinguish between know correctly made nodeids and opaque node ids

i think we should bring it back - its not surprising it was not used - its freshly introduced

Bring what back exactly? OpaqueNodeId?

As mentioned, the distinction only existed because we initially decided that the struct should contain a scope and explicit list of parameters, which I now think it was a bit over-engineering. If we treat a node id just as a path/names/params (and params being an opaque string), then every NodeId is valid, regardless if it was built "live" or from disk.

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

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants