Skip to content

Add public tree read/match/transform utilities - #43

Merged
EthanRosenthal merged 2 commits into
mainfrom
tree-utils
Sep 30, 2026
Merged

EthanRosenthal merged 2 commits into
mainfrom
tree-utils

Conversation

@EthanRosenthal

Copy link
Copy Markdown
Collaborator

Summary

Changing "every X in this config" currently means writing a recursive walk that reads, and often mutates, the private obj._tracked_info["init_args"]. Mutating in place is fragile: the live attributes and the recorded init args (which serialization reads) can drift apart. confingy has no public way to do this safely, so this PR adds one.

New confingy.tree module (exported from confingy)

Function Purpose
get_init_args(node) Copy of the recorded constructor args for a tracked or lazy node
is_fingy_of(obj, cls) Tracked or lazy instance of cls or a subclass, including the subclasses created by track(cls)
walk_fingy(obj) Yields (path, node) for each tracked/lazy node, e.g. "entries['a'].steps[0]"; shared nodes are yielded once
map_fingy(obj, fn) Bottom-up transform; never mutates the input
replace_args(obj, cls, **args) map_fingy + update() on every matching node; returns (new_obj, n_rebuilt)
registry = Registry(entries={
    "a": Pipeline(steps=[Scaler(), "passthrough"]),
    "b": Pipeline(steps=[Scaler(factor=2.0)]),
})

new_registry, n = replace_args(registry, Scaler, factor=3.0)   # n == 2
registry.entries["a"].steps[0].factor                           # 1.0: input unchanged

What map_fingy guarantees:

  • Nodes are rebuilt with update(), so attributes and recorded config can't drift apart.
  • Only the ancestors of changed nodes are rebuilt. Untouched subtrees and containers keep their identity, and map_fingy(x, identity) is x.
  • Lazy nodes stay lazy and tracked nodes stay tracked, including in mixed trees.
  • Shared nodes are transformed once and stay shared.
  • Children are found through init args, inside lists, tuples, namedtuples, dicts (including OrderedDict/defaultdict), sets and dataclasses.
  • Cycles raise ValueError. Nodes created with track(existing_instance) record attributes rather than constructor args, so they raise a ValueError naming their path if one needs rebuilding.

Why not the existing helpers?

  • is_lazy_version_of compares classes with ==. track(T) creates a new subclass on every call, so it never matches lazy(track(T))(...) or lens(track(T)(...)). It is left unchanged, with a docstring note pointing to is_fingy_of.
  • update() rebuilds a single node, so the caller still has to find it and rebuild every ancestor.
  • lens()/unlens() split shared objects into copies and rebuilt every node.

lens() / unlens() changes

  • Both are now memoised by id, so an object shared by two parents is lensed once and stays shared after unlens().
  • unlens() returns the original object for nodes that weren't modified after lens(), and rebuilds only modified nodes and their ancestors. Behaviour change: lens(obj).unlens() with no edits now returns obj itself rather than an equal copy.

Unknown constructor arguments are now rejected up front

Previously the validation model silently ignored unknown kwargs, so a typo in Foo.lazy(...), lazy(Foo)(...), or update(lazy_obj)(...) was accepted and only failed at instantiate(), and replace_args inherited that. The model now forbids extra arguments unless __init__ accepts **kwargs, so all of these raise ValidationError immediately.

  • Behaviour change: for tracked objects, track(Foo)(typo=1) and update(tracked)(typo=1) now raise ValidationError instead of TypeError. This matches how invalid values already fail.
  • If an untracked subclass overrides a tracked base's __init__, the base's wrapper checks only the arguments the base declares, so subclasses that pass a narrower set to super().__init__() keep working.
  • Deserialization is unaffected: it already filters or rejects extra kwargs via strict before constructing. disable_validation() still skips the check.

Other changes

  • track(existing_instance) now records "from_instance": True in _tracked_info. Serialization ignores this key.
  • New confingy.utils.containers helpers are shared by tree.py and unlens().
  • Docs: a "Transforming Whole Trees" section in Diving Deeper, plus API pages for confingy.tree and confingy.utils.containers.

Notes

  • Rebuilding re-runs constructors, and for lazy nodes, validation and __post_config__. This is documented.

Test plan

  • pytest: 347 passed, including 34 new tests in tests/test_tree.py and 5 in TestUnknownArguments
  • ruff check, ruff format --check
  • mypy -p confingy
  • mkdocs build --strict
  • Docstring and docs examples run and produce the documented output

🤖 Generated with Claude Code

EthanRosenthal and others added 2 commits September 29, 2026 15:49
Add confingy.tree with get_init_args, is_fingy_of, walk_fingy, map_fingy,
and replace_args, so callers can inspect and rewrite nested configs without
reading or mutating _tracked_info.

map_fingy rebuilds nodes through update() only when a child changed, so
untouched subtrees keep their identity, laziness is preserved, and shared
nodes are transformed once and stay shared.

lens() and unlens() now memoise by id, so shared objects stay shared, and
unlens() returns the original object for nodes that were not modified.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The validation model used pydantic's default extra="ignore", so unknown
kwargs passed to Cls.lazy(), lazy(), or update() on a Lazy were silently
accepted and only failed at instantiate(). Forbid extras unless __init__
accepts **kwargs.

When an untracked subclass overrides a tracked base's __init__, the base
wrapper now checks only the arguments the base declares, since init_kwargs
follow the subclass signature.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@upwind-code-us

upwind-code-us Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Upwind Upwind Code Scan - ✅ Passed

0 newly introduced vulnerabilities · 0 resolved · 27 total in this PR vs main

Total breakdown: 🔴 1 Critical | 🔶 6 High | 🟡 17 Medium | 🟢 3 Low

View full analysis in Upwind Console

Scan completed in 18s

Scan history (1 scan)
Commit Scanned at New Resolved Net
7d0f80e < 2026-09-29 21:14 UTC 0 0 0

Last scanned: 7d0f80e · 2026-09-29 21:14 UTC

@upwind-code-us

upwind-code-us Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Upwind Upwind IaC Scan - ✅ Passed

0 newly introduced misconfigurations · 0 resolved · 0 total in this PR vs the default branch

View full analysis in Upwind Console →

Scan completed in 3s

Scan history (2 scans)
Commit Scanned at New Resolved Net
7d0f80e 2026-09-29 21:14 UTC — — —
7d0f80e < 2026-09-29 21:14 UTC 0 0 0

Last scanned: 7d0f80e · 2026-09-29 21:14 UTC

@EthanRosenthal
EthanRosenthal merged commit 48e2a1e into main Sep 30, 2026
8 checks passed
@EthanRosenthal
EthanRosenthal deleted the tree-utils branch September 30, 2026 02:23
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.

1 participant