Skip to content

feat: add built-in frozendict support for Python 3.15+#274

Open
XuehaiPan wants to merge 12 commits into
metaopt:mainfrom
XuehaiPan:frozendict
Open

feat: add built-in frozendict support for Python 3.15+#274
XuehaiPan wants to merge 12 commits into
metaopt:mainfrom
XuehaiPan:frozendict

Conversation

@XuehaiPan
Copy link
Copy Markdown
Member

@XuehaiPan XuehaiPan commented Mar 21, 2026

Description

Register frozendict (PEP 814, Python 3.15.0a7+) as a built-in PyTree node type with key-sorted traversal, matching the behavior of dict and collections.defaultdict. The dict_insertion_ordered context manager also affects frozendict.

Also improves the empty deque representation: deque() instead of deque([]).

Motivation and Context

Python 3.15 introduces frozendict as a built-in immutable mapping type (PEP 814). As a fundamental container type, it should be supported as a built-in PyTree node — just like dict, OrderedDict, and defaultdict.

  • I have raised an issue to propose this change (required for new features and bug fixes)

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds core functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation (update in the documentation)

Implemented Tasks

  • C++ PyTreeKind::FrozenDict enum and PyFrozenDictTypeObject macro (guarded by PY_VERSION_HEX >= 0x030F00A7)
  • All treespec switch statements updated (flatten, unflatten, serialize, hash, compare, traverse, construct, paths, accessors, entries)
  • Python registry with sorted and insertion-ordered flatten/unflatten functions
  • treespec_frozendict() constructor, STANDARD_DICT_TYPES expansion, PyTree[T] type union
  • optree.treespec.frozendict namespace alias
  • README (linked to PEP 814), Sphinx docs, and spelling wordlist
  • Test data extended (3 frozendict entries in TREES fixture, subinterpreter test tree, registry size checks)
  • Empty deque repr improvement

Checklist

  • I have read the CONTRIBUTION guide. (required)
  • My change requires a change to the documentation.
  • I have updated the tests accordingly. (required for a bug fix or a new feature)
  • I have updated the documentation accordingly.
  • I have reformatted the code using make format. (required)
  • I have checked the code using make lint. (required)
  • I have ensured make test pass. (required)

@XuehaiPan XuehaiPan self-assigned this Mar 21, 2026
@XuehaiPan XuehaiPan added enhancement New feature or request cxx Something related to the CXX source code python Something related to the Python source code labels Mar 21, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 21, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2e48dac) to head (da07c9f).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #274   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           15        15           
  Lines         1582      1614   +32     
  Branches       210       218    +8     
=========================================
+ Hits          1582      1614   +32     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)
unittests-cp310-Linux 100.00% <100.00%> (ø)
unittests-cp310-Windows 100.00% <100.00%> (ø)
unittests-cp310-macOS 100.00% <100.00%> (ø)
unittests-cp311-Linux 100.00% <100.00%> (ø)
unittests-cp311-Windows 100.00% <100.00%> (ø)
unittests-cp311-macOS 100.00% <100.00%> (ø)
unittests-cp312-Linux 100.00% <100.00%> (ø)
unittests-cp312-Windows 100.00% <100.00%> (ø)
unittests-cp312-macOS 100.00% <100.00%> (ø)
unittests-cp313-Linux 100.00% <100.00%> (ø)
unittests-cp313-Windows 100.00% <100.00%> (ø)
unittests-cp313-macOS 100.00% <100.00%> (ø)
unittests-cp313t-Linux 100.00% <100.00%> (ø)
unittests-cp313t-Windows 100.00% <100.00%> (ø)
unittests-cp313t-macOS 100.00% <100.00%> (ø)
unittests-cp314-Linux 100.00% <100.00%> (ø)
unittests-cp314-Windows 100.00% <100.00%> (ø)
unittests-cp314-macOS 100.00% <100.00%> (ø)
unittests-cp314t-Linux 100.00% <100.00%> (ø)
unittests-cp314t-Windows 100.00% <100.00%> (ø)
unittests-cp314t-macOS 100.00% <100.00%> (ø)
unittests-cp315-Linux 99.92% <96.97%> (?)
unittests-cp315-Windows 99.92% <96.97%> (?)
unittests-cp315-macOS 99.92% <96.97%> (?)
unittests-cp315t-Linux 99.92% <96.97%> (?)
unittests-cp315t-Windows 99.92% <96.97%> (?)
unittests-cp315t-macOS 99.92% <96.97%> (?)
unittests-cp39-Linux 100.00% <100.00%> (ø)
unittests-cp39-Windows 100.00% <100.00%> (ø)
unittests-cp39-macOS 100.00% <100.00%> (ø)
unittests-pp311-Linux 100.00% <100.00%> (ø)
unittests-pp311-Windows 100.00% <100.00%> (ø)
unittests-pp311-macOS 100.00% <100.00%> (ø)
unittests-pydebug 100.00% <100.00%> (ø)
unittests-pydebug-cp310d-Linux 100.00% <100.00%> (ø)
unittests-pydebug-cp310d-Windows 100.00% <100.00%> (ø)
unittests-pydebug-cp310d-macOS 100.00% <100.00%> (ø)
unittests-pydebug-cp311d-Linux 100.00% <100.00%> (ø)
unittests-pydebug-cp311d-Windows 100.00% <100.00%> (ø)
unittests-pydebug-cp311d-macOS 100.00% <100.00%> (ø)
unittests-pydebug-cp312d-Linux 100.00% <100.00%> (ø)
unittests-pydebug-cp312d-Windows 100.00% <100.00%> (ø)
unittests-pydebug-cp312d-macOS 100.00% <100.00%> (ø)
unittests-pydebug-cp313d-Linux 100.00% <100.00%> (ø)
unittests-pydebug-cp313d-Windows 100.00% <100.00%> (ø)
unittests-pydebug-cp313d-macOS 100.00% <100.00%> (ø)
unittests-pydebug-cp313td-Linux 100.00% <100.00%> (ø)
unittests-pydebug-cp313td-Windows 100.00% <100.00%> (ø)
unittests-pydebug-cp313td-macOS 100.00% <100.00%> (ø)
unittests-pydebug-cp314d-Linux 100.00% <100.00%> (ø)
unittests-pydebug-cp314d-Windows 100.00% <100.00%> (ø)
unittests-pydebug-cp314d-macOS 100.00% <100.00%> (ø)
unittests-pydebug-cp314td-Linux 100.00% <100.00%> (ø)
unittests-pydebug-cp314td-Windows 100.00% <100.00%> (ø)
unittests-pydebug-cp314td-macOS 100.00% <100.00%> (ø)
unittests-pydebug-cp315d-Linux 99.92% <96.97%> (?)
unittests-pydebug-cp315d-Windows 99.92% <96.97%> (?)
unittests-pydebug-cp315d-macOS 99.92% <96.97%> (?)
unittests-pydebug-cp315td-Linux 99.92% <96.97%> (?)
unittests-pydebug-cp315td-Windows 99.92% <96.97%> (?)
unittests-pydebug-cp315td-macOS 99.92% <96.97%> (?)
unittests-pydebug-cp39d-Linux 100.00% <100.00%> (ø)
unittests-pydebug-cp39d-Windows 100.00% <100.00%> (ø)
unittests-pydebug-cp39d-macOS 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@XuehaiPan XuehaiPan force-pushed the frozendict branch 4 times, most recently from 3426921 to 12a3276 Compare March 25, 2026 15:25
@XuehaiPan XuehaiPan force-pushed the frozendict branch 4 times, most recently from fe42e10 to 0faccef Compare April 3, 2026 06:14
@XuehaiPan XuehaiPan force-pushed the frozendict branch 5 times, most recently from 19952e3 to 7656326 Compare May 11, 2026 17:39
XuehaiPan added 9 commits May 12, 2026 01:59
Register `frozendict` (Python 3.15.0a7+) as a built-in PyTree node type
with key-sorted traversal, matching the behavior of `dict` and
`collections.defaultdict`. The `dict_insertion_ordered` context manager
also affects `frozendict`.

Changes across 29 files:
- C++ enum `PyTreeKind::FrozenDict` and `PyFrozenDictTypeObject` macro
  (guarded by `PY_VERSION_HEX >= 0x030F00A7`)
- All treespec switch statements updated (flatten, unflatten, serialize,
  hash, compare, traverse, construct, paths, accessors, entries)
- Python registry with sorted and insertion-ordered flatten/unflatten
- `treespec_frozendict()` constructor, `STANDARD_DICT_TYPES` expansion,
  `PyTree[T]` type union update
- `optree.treespec.frozendict` namespace alias
- README, Sphinx docs, and spelling wordlist
- Test data extended (3 frozendict entries in TREES fixture),
  subinterpreter test tree includes frozendict, registry size checks
- Minor: empty deque repr improved (`deque()` instead of `deque([])`)
Sphinx autodoc/autosummary imports symbols during the reading phase
regardless of ``.. only::`` directives, causing import errors on
Python < 3.15 where ``treespec_frozendict`` does not exist. Remove the
entries for now with TODO comments to add them back when building with
Python 3.15+.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds first-class PyTree support for Python 3.15’s new built-in frozendict (PEP 814), aligning its traversal semantics with dict/defaultdict (key-sorted by default, insertion-ordered under dict_insertion_ordered). It also updates documentation, typing exports, CI matrices for Python 3.15, and tweaks the empty deque representation.

Changes:

  • Add PyTreeKind::FrozenDict across the C++ treespec/registry pipeline and expose build-time capability via _C.OPTREE_HAS_FROZENDICT.
  • Register Python-level flatten/unflatten handlers for frozendict, add treespec_frozendict, and extend typing/aliases/STANDARD_DICT_TYPES.
  • Update docs/README/tests/CI to cover and validate frozendict behavior (including subinterpreter tests).

Reviewed changes

Copilot reviewed 33 out of 34 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_treespec.py Adds constructor/from-collection coverage for frozendict treespecs under sorted/insertion-ordered modes.
tests/test_prefix_errors.py Updates expected error-regex to optionally include frozendict in standard-dict type errors.
tests/test_ops.py Extends flatten/unflatten metadata assertions to cover PyTreeKind.FROZENDICT.
tests/helpers.py Adds OPTREE_HAS_FROZENDICT gating, bumps expected builtin registry size, and extends shared tree/path/accessor fixtures with frozendict.
tests/concurrent/test_subinterpreters.py Adjusts subinterpreter registry-size checks and adds optional frozendict into the exercised tree/leaves expectations.
src/treespec/unflatten.cpp Includes FrozenDict in unflatten switch paths.
src/treespec/treespec.cpp Creates nodes/types for FrozenDict and extends multiple treespec operations to treat it as a dict-like node kind.
src/treespec/traversal.cpp Extends iterator/walk traversal logic to include FrozenDict among dict-like node kinds.
src/treespec/serialization.cpp Adds FrozenDict kind name and string rendering, and includes it in pickling/unpickling state handling.
src/treespec/richcomparison.cpp Extends prefix comparison to consider FrozenDict compatible with dict-like kinds.
src/treespec/hashing.cpp Extends hashing implementation to include FrozenDict in dict-like hashing.
src/treespec/flatten.cpp Extends flatten/flatten-with-path/flatten-up-to code paths and type-mismatch errors to include FrozenDict.
src/treespec/constructors.cpp Enables treespec constructors to recognize/construct FrozenDict kind.
src/registry.cpp Registers built-in frozendict type in the C++ registry when available.
src/optree.cpp Exposes OPTREE_HAS_FROZENDICT metadata and adds PyTreeKind.FROZENDICT to the Python enum.
requirements.txt Adds a Python 3.15+ typing-extensions minimum constraint.
README.md Documents frozendict as a built-in supported container and clarifies sorted vs insertion-ordered behavior.
pyproject.toml Updates runtime/test dependency markers for typing-extensions on Python 3.15+.
optree/typing.py Exports FrozenDict typing alias and extends PyTree[T] union to include frozendict when available.
optree/treespec.py Adds optree.treespec.frozendict alias (gated by Python/version capability).
optree/registry.py Registers frozendict node handlers (sorted and insertion-ordered variants) and integrates with insertion-order registry logic.
optree/ops.py Adds treespec_frozendict, extends docs for dict-like ordering semantics, and updates STANDARD_DICT_TYPES.
optree/_C.pyi Adds OPTREE_HAS_FROZENDICT and PyTreeKind.FROZENDICT to the stub.
optree/init.py Conditionally re-exports treespec_frozendict at package top-level.
include/optree/treespec.h Updates comments describing metadata/original-keys semantics to include FrozenDict.
include/optree/registry.h Adds FrozenDict to PyTreeKind enum and constants.
include/optree/pytypes.h Adds frozendict type-object macro and includes frozendict in “standard dict” validation/error messages.
include/optree/pymacros.h Defines OPTREE_HAS_FROZENDICT based on PY_VERSION_HEX >= 0x030F00A7.
docs/source/treespec.rst Notes pending doc autosummary integration for frozendict when built on Python 3.15+.
docs/source/spelling_wordlist.txt Adds frozendict to spelling wordlist.
docs/source/ops.rst Notes pending Sphinx function doc inclusion for treespec_frozendict when building on Python 3.15+.
.github/workflows/tests.yml Adds Python 3.15 and 3.15t to the test matrix.
.github/workflows/tests-with-pydebug.yml Adds Python 3.15 to the pydebug matrix.
.github/workflows/build.yml Extends setup-python range to include 3.15 and leaves commented placeholders for 3.15 build targets.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/treespec/serialization.cpp
Comment thread src/treespec/treespec.cpp
Comment thread src/treespec/treespec.cpp
Address Copilot PR review feedback (PR metaopt#274). The `PyTreeKind::FrozenDict`
enum value exists unconditionally to keep C++ `switch` statements exhaustive
(silences MSVC C4061), but on builds without `OPTREE_HAS_FROZENDICT` the
downstream sites silently fell through, demoting `FrozenDict` to a mutable
`dict` or hitting `INTERNAL_ERROR()`. A treespec deserialized from a Python
3.15+ build could therefore round-trip incorrectly on older interpreters.

C++ hardening:

- `src/treespec/serialization.cpp`: reject `FrozenDict` at the deserialization
  boundary with an actionable `py::value_error` so cross-version state fails
  loudly at the trust boundary rather than later in unrelated switches.
- `src/treespec/treespec.cpp` (`MakeNode`, `GetType`): explicit `#else throw
  py::value_error` branches instead of silent fall-through / dict demotion.
- `src/treespec/flatten.cpp` (`FlattenUpTo`): replace terminal `else` that
  mislabeled any unknown kind as "frozendict" with an explicit `FrozenDict`
  branch plus `INTERNAL_ERROR()` default.
- `src/treespec/richcomparison.cpp` (`IsPrefix`): document why all dict-family
  kinds are treated as prefix-compatible with one another.

Python/docs polish:

- `optree/typing.py`: drop the redundant lowercase `frozendict` import; use
  the public `FrozenDict` alias internally for the `PyTree[T]` union too.
- `optree/ops.py`: rewrite every `treespec_*` docstring opening line to the
  consistent form "Make a treespec representing a :class:`X` node from child
  treespecs."
- `include/optree/treespec.h`: clarify that `FrozenDict` keys are sorted by
  default and insertion-ordered under `dict_insertion_ordered`.
- `docs/source/{ops,treespec}.rst`: tighten the pending-autodoc TODO to note
  it activates when building docs with Python 3.15+.

Tests (gated on Python 3.15+):

- `tests/test_treespec.py`: explicit inequality of `frozendict` treespec vs
  `dict`/`OrderedDict`/`defaultdict` (incl. hash difference, empty case, and
  pickle round-trip kind preservation); `dict_insertion_ordered` behavior
  parity with `dict`.
- `tests/test_prefix_errors.py`: convert the relaxed error-message regex into
  a positive assertion that "frozendict" appears in the type list and that a
  `dict` is accepted as a prefix of a `frozendict`.
@XuehaiPan XuehaiPan requested a review from Copilot May 13, 2026 10:09
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 33 out of 34 changed files in this pull request and generated 3 comments.

Comment thread docs/source/treespec.rst Outdated
Comment thread docs/source/ops.rst Outdated
Comment thread optree/ops.py
XuehaiPan added a commit to XuehaiPan/optree that referenced this pull request May 13, 2026
Address Copilot PR review feedback (PR metaopt#274). The `PyTreeKind::FrozenDict`
enum value exists unconditionally to keep C++ `switch` statements exhaustive
(silences MSVC C4061), but on builds without `OPTREE_HAS_FROZENDICT` the
downstream sites silently fell through, demoting `FrozenDict` to a mutable
`dict` or hitting `INTERNAL_ERROR()`. A treespec deserialized from a Python
3.15+ build could therefore round-trip incorrectly on older interpreters.

C++ hardening:

- `src/treespec/serialization.cpp`: reject `FrozenDict` at the deserialization
  boundary with an actionable `py::value_error` so cross-version state fails
  loudly at the trust boundary rather than later in unrelated switches.
- `src/treespec/treespec.cpp` (`MakeNode`, `GetType`): explicit `#else throw
  py::value_error` branches instead of silent fall-through / dict demotion.
- `src/treespec/flatten.cpp` (`FlattenUpTo`): replace terminal `else` that
  mislabeled any unknown kind as "frozendict" with an explicit `FrozenDict`
  branch plus `INTERNAL_ERROR()` default.
- `src/treespec/richcomparison.cpp` (`IsPrefix`): document why all dict-family
  kinds are treated as prefix-compatible with one another.

Python/docs polish:

- `optree/typing.py`: drop the redundant lowercase `frozendict` import; use
  the public `FrozenDict` alias internally for the `PyTree[T]` union too.
- `optree/ops.py`: rewrite every `treespec_*` docstring opening line to the
  consistent form "Make a treespec representing a :class:`X` node from child
  treespecs."
- `include/optree/treespec.h`: clarify that `FrozenDict` keys are sorted by
  default and insertion-ordered under `dict_insertion_ordered`.
- `docs/source/{ops,treespec}.rst`: tighten the pending-autodoc TODO to note
  it activates when building docs with Python 3.15+.

Tests (gated on Python 3.15+):

- `tests/test_treespec.py`: explicit inequality of `frozendict` treespec vs
  `dict`/`OrderedDict`/`defaultdict` (incl. hash difference, empty case, and
  pickle round-trip kind preservation); `dict_insertion_ordered` behavior
  parity with `dict`.
- `tests/test_prefix_errors.py`: convert the relaxed error-message regex into
  a positive assertion that "frozendict" appears in the type list and that a
  `dict` is accepted as a prefix of a `frozendict`.
@XuehaiPan XuehaiPan requested a review from Copilot May 13, 2026 10:36
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@XuehaiPan XuehaiPan requested a review from Copilot May 13, 2026 18:01
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 33 out of 34 changed files in this pull request and generated 14 comments.

Comments suppressed due to low confidence (2)

tests/test_prefix_errors.py:1

  • The dots in collections.OrderedDict and collections.defaultdict are unescaped regex metacharacters. They will match any character. Escape them as collections\.OrderedDict / collections\.defaultdict to avoid accidental matches and for consistency with the escaping used elsewhere in this file.
    tests/helpers.py:1
  • Hard-coding 9 if … else 8 and repeating the same expression in tests/concurrent/test_subinterpreters.py (twice) couples three test sites to the same magic constant. Consider exposing the expected size from helpers.py (e.g., EXPECTED_REGISTRY_SIZE) and importing it where needed, so future builtin additions only need to update one place.

Comment thread optree/ops.py Outdated
Comment thread optree/registry.py
Comment thread optree/ops.py
Comment thread optree/treespec.py
Comment thread README.md
Comment thread src/treespec/serialization.cpp
Comment thread .github/workflows/build.yml Outdated
Comment thread optree/ops.py
Comment thread optree/typing.py
Comment thread tests/concurrent/test_subinterpreters.py
…all time

Address Copilot PR review on `docs/source/ops.rst` and `docs/source/treespec.rst`:
the previous `TODO(frozendict): Add ... when building docs with Python 3.15+` comments
meant the new API was perpetually undocumented even on toolchains that could support
it, and conditional Sphinx directives (`only::`, `ifconfig::`) don't actually suppress
the autodoc/autosummary import warnings emitted when the symbol is missing — those
extensions resolve symbols at directive-parse / pre-scan time, before any
rendering-side filter applies.

Move the version gating from definition-time to call-time so the symbol always
exists. `autodoc` and `autosummary` can introspect it on every supported Python; the
docstring's `.. note::` block explicitly states the runtime requirement. Calling the
function on a build without `frozendict` support raises a clear `RuntimeError`
naming the version requirement, rather than the previous `AttributeError` users got
when the symbol was conditionally defined.

API changes:

- `optree.treespec_frozendict` is now defined unconditionally (always callable,
  always in `optree.__all__`), with a runtime guard that raises `RuntimeError` if
  `_C.OPTREE_HAS_FROZENDICT` is unset.
- `optree.treespec.frozendict` mirrors the same: unconditional re-export of
  `treespec_frozendict`, always in `optree.treespec.__all__`.
- Dropped now-unused `import sys` / `import optree._C as _C` / `del sys` from
  `optree/__init__.py` and `optree/treespec.py`.
- Moved `import builtins` from `TYPE_CHECKING` to module-top in `optree/ops.py`
  since it's accessed at runtime via `builtins.frozendict`.

Docs:

- `docs/source/ops.rst`: replace the TODO with the actual
  `.. autofunction:: treespec_frozendict` directive and add the symbol to the
  surrounding autosummary block.
- `docs/source/treespec.rst`: replace the TODO with a `frozendict` entry in the
  autosummary list.
- Result: `make docs` is warning-free on every supported Python — the symbol is
  always importable so `autodoc`/`autosummary` succeed.

Docstring polish: rewrite the opening line of every `treespec_*` constructor's
docstring to the consistent form `Make a treespec representing a :class:`X` node
from child treespecs.` and use `:class:` markup uniformly in the `Returns:` block
(tuple, list, dict, OrderedDict, defaultdict, deque).

Tests:

- New `tests/test_treespec.py::test_treespec_frozendict_runtime_error_on_unsupported_interpreter`:
  on Python < 3.15 (or wheels without `frozendict` support), both
  `optree.treespec_frozendict()` and `optree.treespec.frozendict(...)` raise a
  `RuntimeError` whose message contains the exact version requirement.

Drive-by: add `# pylint: disable=redefined-builtin` to two `sentinel`
references in `tree_replace_nones` (parameter) and `tree_broadcast_common` (local).
Python 3.15 added `builtins.sentinel` (PEP 661), which makes pylint flag prior
uses of the name; surfaced now that the lint venv runs on 3.15t.
@XuehaiPan XuehaiPan force-pushed the frozendict branch 3 times, most recently from 0795f05 to 2792b40 Compare May 14, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cxx Something related to the CXX source code enhancement New feature or request python Something related to the Python source code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants