From 9b689e46bd0a98bd499836b180b82cb2fbcb22e1 Mon Sep 17 00:00:00 2001 From: Adi Wadaskar Date: Tue, 9 Jun 2026 05:34:05 +0000 Subject: [PATCH 1/3] Fix config_store map_annotation on Python 3.14 (read-only __args__) map_annotation rebuilds Union/list/dict/tuple generics after remapping their inner types (e.g. torch.Tensor -> Any). For typing aliases it did `copy.copy(annotation); annotation.__args__ = args`. Python 3.14 unified typing.Union with the builtin X | Y union type (types.UnionType) -- they are now the same C-implemented class whose __args__ is a read-only slot, so the in-place assignment raises `AttributeError: readonly attribute` at import/registration time for any class registered via @register whose __init__ has a Union/Optional annotation. (CPython gh-105499; What's New in Python 3.14.) Rebuild without mutation instead: - Union/Optional -> Union[tuple(args)] - other typing aliases -> annotation.copy_with(tuple(args)), with the legacy in-place rewrite kept only as a fallback. Behavior-preserving on Python 3.10-3.13; unblocks 3.14. Adds Union/Optional regression assertions to test_map_annotation. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ test/test_config_store.py | 10 +++++++++- torch_geometric/config_store.py | 20 +++++++++++++++++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4dde004d4c2..bd66bac41a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- Fixed `config_store` on Python 3.14, where `typing.Union`/`typing.Optional` annotations could no longer be remapped in-place via the now read-only `__args__` attribute + ### Security ## [2.8.0] - 2026-06-05 diff --git a/test/test_config_store.py b/test/test_config_store.py index 9c0179b9bbdb..ab68a73e84d5 100644 --- a/test/test_config_store.py +++ b/test/test_config_store.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union from torch_geometric.config_store import ( class_from_dataclass, @@ -58,6 +58,14 @@ def test_map_annotation(): assert map_annotation(list[int], mapping) == list[Any] assert map_annotation(tuple[int], mapping) == tuple[Any] + # `typing.Union`/`typing.Optional` must be rebuilt without mutating + # `__args__`, which is read-only on Python >= 3.14: + assert map_annotation(Optional[int], mapping) == Optional[Any] + assert map_annotation(Union[int, str], mapping) == Union[Any, str] + assert map_annotation(List[Optional[int]], mapping) == List[Optional[Any]] + assert map_annotation(Dict[str, Optional[int]], + mapping) == Dict[str, Optional[Any]] + def test_register(): register(AddSelfLoops, group='transform') diff --git a/torch_geometric/config_store.py b/torch_geometric/config_store.py index b45a267abd41..dd5ac467df2d 100644 --- a/torch_geometric/config_store.py +++ b/torch_geometric/config_store.py @@ -170,10 +170,24 @@ def map_annotation( if type(annotation).__name__ == 'GenericAlias': # If annotated with `list[...]` or `dict[...]`: annotation = origin[args] + elif origin is Union: + # If annotated with `typing.Union[...]` or `typing.Optional[...]`. + # Rebuild instead of mutating `__args__`, which is read-only on + # Python >= 3.14 (`typing.Union` instances no longer allow + # in-place attribute assignment). + annotation = Union[args] else: - # If annotated with `typing.List[...]` or `typing.Dict[...]`: - annotation = copy.copy(annotation) - annotation.__args__ = args + # If annotated with `typing.List[...]` or `typing.Dict[...]`. + # Prefer the non-mutating `copy_with` reconstruction; fall back to + # the legacy in-place rewrite only if it is unavailable. The + # in-place `annotation.__args__ = args` path raises on + # Python >= 3.14, where generic-alias `__args__` is read-only. + copy_with = getattr(annotation, 'copy_with', None) + if copy_with is not None: + annotation = copy_with(tuple(args)) + else: + annotation = copy.copy(annotation) + annotation.__args__ = args return annotation From 746828be6a66bc72f853a05d9c2f6912d736277b Mon Sep 17 00:00:00 2001 From: Adi Wadaskar Date: Tue, 9 Jun 2026 05:41:51 +0000 Subject: [PATCH 2/3] test(config_store): assert Union-collapse maps to canonical Any Locks in the one behavioral improvement of the 3.14 __args__ fix: when all Union members map to the same type, the rebuilt annotation collapses to Any (old in-place mutation left a degenerate Union[Any, Any]). Co-Authored-By: Claude Opus 4.8 --- test/test_config_store.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/test_config_store.py b/test/test_config_store.py index ab68a73e84d5..1ea2bf24cd24 100644 --- a/test/test_config_store.py +++ b/test/test_config_store.py @@ -65,6 +65,10 @@ def test_map_annotation(): assert map_annotation(List[Optional[int]], mapping) == List[Optional[Any]] assert map_annotation(Dict[str, Optional[int]], mapping) == Dict[str, Optional[Any]] + # When every Union member maps to the same type, the rebuilt annotation + # collapses to canonical `Any` (the old in-place mutation left a + # degenerate `Union[Any, Any]`): + assert map_annotation(Union[int, str], {int: Any, str: Any}) == Any def test_register(): From 5cc3b10328cc427d6d96e4376a818c1fd304b6af Mon Sep 17 00:00:00 2001 From: Adi Wadaskar Date: Wed, 10 Jun 2026 22:46:36 +0000 Subject: [PATCH 3/3] Simplify map_annotation: drop unreachable copy fallback --- torch_geometric/config_store.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/torch_geometric/config_store.py b/torch_geometric/config_store.py index dd5ac467df2d..df6cb2f105c4 100644 --- a/torch_geometric/config_store.py +++ b/torch_geometric/config_store.py @@ -1,4 +1,3 @@ -import copy import inspect import typing from collections import defaultdict @@ -178,16 +177,9 @@ def map_annotation( annotation = Union[args] else: # If annotated with `typing.List[...]` or `typing.Dict[...]`. - # Prefer the non-mutating `copy_with` reconstruction; fall back to - # the legacy in-place rewrite only if it is unavailable. The - # in-place `annotation.__args__ = args` path raises on - # Python >= 3.14, where generic-alias `__args__` is read-only. - copy_with = getattr(annotation, 'copy_with', None) - if copy_with is not None: - annotation = copy_with(tuple(args)) - else: - annotation = copy.copy(annotation) - annotation.__args__ = args + # Rebuild via `copy_with` rather than mutating `__args__`, which is + # read-only on Python >= 3.14. + annotation = annotation.copy_with(tuple(args)) return annotation