morphic 0.2.3__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {morphic-0.2.3 → morphic-0.2.4}/PKG-INFO +1 -1
- {morphic-0.2.3 → morphic-0.2.4}/src/morphic/typed.py +41 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_typed_typed_path.py +354 -3
- {morphic-0.2.3 → morphic-0.2.4}/.cursor/rules/morphic-standards.mdc +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/.cursor/rules/typed-registry-examples.mdc +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/.github/workflows/docs.yml +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/.github/workflows/linting.yml +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/.github/workflows/release.yml +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/.github/workflows/tests.yml +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/.gitignore +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/LICENSE +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/README.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/api/autoenum.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/api/index.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/api/registry.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/api/string.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/api/typed.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/examples.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/index.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/installation.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/stylesheets/extra.css +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/autoenum.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/getting-started.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/registry.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/string.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/typed-cli.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/typed-registry-integration.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/typed.md +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/mkdocs.yml +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/pyproject.toml +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/src/morphic/__init__.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/src/morphic/autoenum.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/src/morphic/classproperty.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/src/morphic/function.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/src/morphic/imports.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/src/morphic/registry.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/src/morphic/string.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/src/morphic/string_data.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/src/morphic/structs.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/__init__.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_autoenum.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_classproperty.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_function.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_imports.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_registry.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_string.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_structs.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_typed.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_typed_basesettings.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_typed_cli_native_edge_cases.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_typed_registry_cli_dispatch.py +0 -0
- {morphic-0.2.3 → morphic-0.2.4}/tests/test_typed_registry_integration.py +0 -0
|
@@ -408,11 +408,52 @@ def _typed_path_resolve_in_dict(cls: type, data: Dict[str, Any]) -> None:
|
|
|
408
408
|
the top level) and from ``_typed_path_recursive_load`` (when a
|
|
409
409
|
parent file is being loaded and its loaded-dict needs to have ITS
|
|
410
410
|
nested TypedPath fields resolved against the parent's directory).
|
|
411
|
+
|
|
412
|
+
Registry dispatch: when ``cls`` is a ``Typed + Registry`` abstract
|
|
413
|
+
base AND ``data`` carries a ``__type__`` discriminator, this method
|
|
414
|
+
switches ``cls`` to the resolved concrete subclass for the field
|
|
415
|
+
iteration. This is necessary because TypedPath fields are typically
|
|
416
|
+
declared on concrete subclasses, not on the abstract base — and
|
|
417
|
+
without this swap, those fields would be invisible during the
|
|
418
|
+
in-band recursive load (causing relative paths inside the loaded
|
|
419
|
+
dict to never get resolved against the parent file's directory).
|
|
411
420
|
"""
|
|
412
421
|
if not isinstance(data, dict):
|
|
413
422
|
return
|
|
414
423
|
if not hasattr(cls, "model_fields"):
|
|
415
424
|
return
|
|
425
|
+
|
|
426
|
+
## Registry-dispatch step: when the loaded dict carries a
|
|
427
|
+
## ``__type__`` discriminator AND ``cls`` is an abstract Registry
|
|
428
|
+
## base, swap to the concrete subclass before iterating fields.
|
|
429
|
+
##
|
|
430
|
+
## Why: ``cls.model_fields`` reflects only fields declared directly
|
|
431
|
+
## on ``cls``. Concrete subclasses commonly add their own
|
|
432
|
+
## TypedPath-annotated fields (e.g., a backend-specific config
|
|
433
|
+
## field) that the abstract base doesn't have. Without this swap,
|
|
434
|
+
## the field iteration below misses those fields and any relative
|
|
435
|
+
## paths they reference get evaluated later, after the
|
|
436
|
+
## directory-context ContextVar has been reset by the parent's
|
|
437
|
+
## try/finally block — leading to "FileNotFoundError" against the
|
|
438
|
+
## user's cwd instead of the parent file's directory.
|
|
439
|
+
##
|
|
440
|
+
## The actual Pydantic-level dispatch (constructing the right
|
|
441
|
+
## subclass with the loaded dict) still happens later in the normal
|
|
442
|
+
## validation flow via Typed's ``__new__`` discriminator handling.
|
|
443
|
+
## This step only borrows the discriminator to choose which class's
|
|
444
|
+
## ``model_fields`` to iterate for nested-path resolution.
|
|
445
|
+
discriminator = data.get(TYPED_REGISTRY_DISCRIMINATOR_KEY)
|
|
446
|
+
if discriminator is not None and isinstance(cls, type) and issubclass(cls, Registry):
|
|
447
|
+
try:
|
|
448
|
+
concrete_cls = cls.get_subclass(discriminator, raise_error=False)
|
|
449
|
+
except Exception: # noqa: BLE001
|
|
450
|
+
## Any failure to look up the subclass falls through to the
|
|
451
|
+
## abstract-base field iteration. The user will see the
|
|
452
|
+
## standard Registry KeyError later when Pydantic dispatches.
|
|
453
|
+
concrete_cls = None
|
|
454
|
+
if concrete_cls is not None and concrete_cls is not cls:
|
|
455
|
+
cls = concrete_cls
|
|
456
|
+
|
|
416
457
|
for fname, finfo in cls.model_fields.items():
|
|
417
458
|
if fname not in data:
|
|
418
459
|
continue
|
|
@@ -31,10 +31,8 @@ slots cleanly into the existing pipeline.
|
|
|
31
31
|
from __future__ import annotations
|
|
32
32
|
|
|
33
33
|
import json
|
|
34
|
-
import os
|
|
35
34
|
import subprocess
|
|
36
35
|
import sys
|
|
37
|
-
import tempfile
|
|
38
36
|
import textwrap
|
|
39
37
|
from pathlib import Path
|
|
40
38
|
from typing import Annotated, List, Optional
|
|
@@ -43,7 +41,6 @@ import pytest
|
|
|
43
41
|
|
|
44
42
|
from morphic import Typed, TypedPath
|
|
45
43
|
|
|
46
|
-
|
|
47
44
|
# ---------------------------------------------------------------------------
|
|
48
45
|
# Subprocess helper for end-to-end CLI integration tests.
|
|
49
46
|
# ---------------------------------------------------------------------------
|
|
@@ -1004,3 +1001,357 @@ class TestWeirdEdgeCases:
|
|
|
1004
1001
|
## explicitly converted (str()):
|
|
1005
1002
|
o = Outer(inner=str(MyPath(path)))
|
|
1006
1003
|
assert o.inner.name == "fspath"
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
# ===========================================================================
|
|
1007
|
+
# Registry dispatch composed with TypedPath
|
|
1008
|
+
# ===========================================================================
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
class TestTypedPathWithRegistryDispatch:
|
|
1012
|
+
"""The ``Annotated[AbstractBase, TypedPath]`` + ``__type__`` pattern.
|
|
1013
|
+
|
|
1014
|
+
Scenario: a field is annotated with an abstract ``Typed + Registry``
|
|
1015
|
+
base, AND has the ``TypedPath`` marker. The on-disk JSON/YAML file
|
|
1016
|
+
contains a ``__type__`` discriminator key plus subclass-specific
|
|
1017
|
+
fields (some of which themselves use ``TypedPath`` for relative paths).
|
|
1018
|
+
|
|
1019
|
+
The contract: relative paths declared on the SUBCLASS (not on the
|
|
1020
|
+
abstract base) must still be resolved against the loaded file's
|
|
1021
|
+
directory. Without the discriminator-aware dispatch in
|
|
1022
|
+
``_typed_path_resolve_in_dict``, those subclass-only TypedPath
|
|
1023
|
+
fields would be invisible during the in-band recursive load, and
|
|
1024
|
+
their relative paths would later be resolved against the user's
|
|
1025
|
+
cwd instead of the parent file's directory.
|
|
1026
|
+
"""
|
|
1027
|
+
|
|
1028
|
+
def _make_classes(self):
|
|
1029
|
+
"""Build a fresh class hierarchy for each test (avoids Registry
|
|
1030
|
+
cross-test pollution)."""
|
|
1031
|
+
from abc import ABC
|
|
1032
|
+
|
|
1033
|
+
from morphic import Registry
|
|
1034
|
+
|
|
1035
|
+
class LeafConfig(Typed):
|
|
1036
|
+
label: str
|
|
1037
|
+
count: int = 0
|
|
1038
|
+
|
|
1039
|
+
class AbstractBase(Typed, Registry, ABC):
|
|
1040
|
+
"""Registry root. Has no TypedPath fields itself."""
|
|
1041
|
+
|
|
1042
|
+
base_field: str = "default-base"
|
|
1043
|
+
|
|
1044
|
+
class ConcreteWithPath(AbstractBase):
|
|
1045
|
+
"""Subclass that adds a TypedPath-annotated field."""
|
|
1046
|
+
|
|
1047
|
+
aliases = ("with_path",)
|
|
1048
|
+
leaf: Annotated[LeafConfig, TypedPath]
|
|
1049
|
+
|
|
1050
|
+
class ConcreteWithoutPath(AbstractBase):
|
|
1051
|
+
"""Subclass that adds a non-TypedPath field."""
|
|
1052
|
+
|
|
1053
|
+
aliases = ("without_path",)
|
|
1054
|
+
number: int = 7
|
|
1055
|
+
|
|
1056
|
+
class Holder(Typed):
|
|
1057
|
+
target: Annotated[AbstractBase, TypedPath]
|
|
1058
|
+
|
|
1059
|
+
return LeafConfig, AbstractBase, ConcreteWithPath, ConcreteWithoutPath, Holder
|
|
1060
|
+
|
|
1061
|
+
def test_subclass_typed_path_field_resolves_relative_path_against_parent_file(
|
|
1062
|
+
self,
|
|
1063
|
+
tmp_path: Path,
|
|
1064
|
+
) -> None:
|
|
1065
|
+
"""The core regression test for the agent's fix.
|
|
1066
|
+
|
|
1067
|
+
- Outer config file references ``../leaf/leaf.json`` for the
|
|
1068
|
+
subclass-only ``leaf`` field.
|
|
1069
|
+
- Without the fix: the abstract base's ``model_fields`` doesn't
|
|
1070
|
+
include ``leaf``, so the recursive load skips it. Later
|
|
1071
|
+
Pydantic tries to resolve ``../leaf/leaf.json`` against the
|
|
1072
|
+
user's cwd → ``FileNotFoundError``.
|
|
1073
|
+
- With the fix: the discriminator is read, the concrete
|
|
1074
|
+
subclass is selected, ``leaf`` is found in its
|
|
1075
|
+
``model_fields``, and the relative path is resolved against
|
|
1076
|
+
the parent file's directory.
|
|
1077
|
+
"""
|
|
1078
|
+
LeafConfig, AbstractBase, ConcreteWithPath, ConcreteWithoutPath, Holder = self._make_classes()
|
|
1079
|
+
|
|
1080
|
+
(tmp_path / "configs" / "outer").mkdir(parents=True)
|
|
1081
|
+
(tmp_path / "configs" / "leaf").mkdir(parents=True)
|
|
1082
|
+
|
|
1083
|
+
(tmp_path / "configs" / "leaf" / "leaf.json").write_text(
|
|
1084
|
+
json.dumps(
|
|
1085
|
+
{
|
|
1086
|
+
"label": "leaf-from-file",
|
|
1087
|
+
"count": 99,
|
|
1088
|
+
}
|
|
1089
|
+
)
|
|
1090
|
+
)
|
|
1091
|
+
(tmp_path / "configs" / "outer" / "with_path.json").write_text(
|
|
1092
|
+
json.dumps(
|
|
1093
|
+
{
|
|
1094
|
+
"__type__": "with_path",
|
|
1095
|
+
"leaf": "../leaf/leaf.json", # subclass-only TypedPath field
|
|
1096
|
+
}
|
|
1097
|
+
)
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
h = Holder(
|
|
1101
|
+
target=str(tmp_path / "configs" / "outer" / "with_path.json"),
|
|
1102
|
+
)
|
|
1103
|
+
assert type(h.target) is ConcreteWithPath
|
|
1104
|
+
assert h.target.leaf.label == "leaf-from-file"
|
|
1105
|
+
assert h.target.leaf.count == 99
|
|
1106
|
+
|
|
1107
|
+
def test_subclass_with_no_typed_path_fields_works(self, tmp_path: Path) -> None:
|
|
1108
|
+
"""Switching to the concrete subclass also works when the subclass
|
|
1109
|
+
has no TypedPath fields at all (the field iteration just finds
|
|
1110
|
+
nothing to load, which is fine)."""
|
|
1111
|
+
LeafConfig, AbstractBase, ConcreteWithPath, ConcreteWithoutPath, Holder = self._make_classes()
|
|
1112
|
+
|
|
1113
|
+
path = tmp_path / "without.json"
|
|
1114
|
+
path.write_text(json.dumps({"__type__": "without_path", "number": 42}))
|
|
1115
|
+
|
|
1116
|
+
h = Holder(target=str(path))
|
|
1117
|
+
assert type(h.target) is ConcreteWithoutPath
|
|
1118
|
+
assert h.target.number == 42
|
|
1119
|
+
|
|
1120
|
+
def test_unknown_discriminator_falls_through_gracefully(self, tmp_path: Path) -> None:
|
|
1121
|
+
"""If the discriminator value isn't a registered subclass key,
|
|
1122
|
+
the dispatch silently falls back to the abstract base. The
|
|
1123
|
+
actual subclass-resolution error surfaces later, during normal
|
|
1124
|
+
Pydantic validation, with the standard Registry message."""
|
|
1125
|
+
LeafConfig, AbstractBase, ConcreteWithPath, ConcreteWithoutPath, Holder = self._make_classes()
|
|
1126
|
+
|
|
1127
|
+
path = tmp_path / "bogus.json"
|
|
1128
|
+
path.write_text(
|
|
1129
|
+
json.dumps(
|
|
1130
|
+
{
|
|
1131
|
+
"__type__": "totally_does_not_exist",
|
|
1132
|
+
"leaf": "leaf.json",
|
|
1133
|
+
}
|
|
1134
|
+
)
|
|
1135
|
+
)
|
|
1136
|
+
with pytest.raises((KeyError, ValueError)):
|
|
1137
|
+
Holder(target=str(path))
|
|
1138
|
+
|
|
1139
|
+
def test_alias_in_discriminator_works(self, tmp_path: Path) -> None:
|
|
1140
|
+
"""The discriminator can use any alias the subclass declared
|
|
1141
|
+
(not just the class name)."""
|
|
1142
|
+
LeafConfig, AbstractBase, ConcreteWithPath, ConcreteWithoutPath, Holder = self._make_classes()
|
|
1143
|
+
|
|
1144
|
+
(tmp_path / "leaf").mkdir()
|
|
1145
|
+
(tmp_path / "leaf" / "leaf.json").write_text(json.dumps({"label": "L"}))
|
|
1146
|
+
outer = tmp_path / "outer.json"
|
|
1147
|
+
outer.write_text(
|
|
1148
|
+
json.dumps(
|
|
1149
|
+
{
|
|
1150
|
+
"__type__": "with_path", # alias declared on ConcreteWithPath
|
|
1151
|
+
"leaf": "leaf/leaf.json",
|
|
1152
|
+
}
|
|
1153
|
+
)
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
h = Holder(target=str(outer))
|
|
1157
|
+
assert type(h.target) is ConcreteWithPath
|
|
1158
|
+
assert h.target.leaf.label == "L"
|
|
1159
|
+
|
|
1160
|
+
def test_no_discriminator_no_dispatch_keeps_abstract_iteration(
|
|
1161
|
+
self,
|
|
1162
|
+
tmp_path: Path,
|
|
1163
|
+
) -> None:
|
|
1164
|
+
"""If the loaded dict has no ``__type__`` key, the dispatch step
|
|
1165
|
+
is a no-op — ``cls`` stays as the abstract base. The dict is
|
|
1166
|
+
validated against the abstract base's fields (which may or may
|
|
1167
|
+
not be enough to instantiate, depending on whether the abstract
|
|
1168
|
+
base has any ``@abstractmethod`` decorations enforcing
|
|
1169
|
+
abstractness).
|
|
1170
|
+
|
|
1171
|
+
This test pins the no-dispatch path: the path-load step doesn't
|
|
1172
|
+
crash on a dict without ``__type__``, and doesn't silently
|
|
1173
|
+
invent a subclass.
|
|
1174
|
+
"""
|
|
1175
|
+
LeafConfig, AbstractBase, ConcreteWithPath, ConcreteWithoutPath, Holder = self._make_classes()
|
|
1176
|
+
|
|
1177
|
+
path = tmp_path / "no_disc.json"
|
|
1178
|
+
path.write_text(json.dumps({"base_field": "no-disc"}))
|
|
1179
|
+
|
|
1180
|
+
## With this AbstractBase (no @abstractmethod), the dict is
|
|
1181
|
+
## actually instantiable as the bare base class. The important
|
|
1182
|
+
## point is that the result is the abstract base type, NOT
|
|
1183
|
+
## some accidentally-dispatched subclass.
|
|
1184
|
+
h = Holder(target=str(path))
|
|
1185
|
+
assert type(h.target) is AbstractBase
|
|
1186
|
+
assert h.target.base_field == "no-disc"
|
|
1187
|
+
|
|
1188
|
+
def test_pre_built_subclass_instance_preserves_identity(self, tmp_path: Path) -> None:
|
|
1189
|
+
"""Passing a pre-built concrete subclass instance still preserves
|
|
1190
|
+
identity. The fix only triggers when the value is a path
|
|
1191
|
+
(loaded from disk), not when it's already an instance."""
|
|
1192
|
+
LeafConfig, AbstractBase, ConcreteWithPath, ConcreteWithoutPath, Holder = self._make_classes()
|
|
1193
|
+
|
|
1194
|
+
## Construct the concrete subclass with a pre-built leaf:
|
|
1195
|
+
leaf = LeafConfig(label="prebuilt", count=1)
|
|
1196
|
+
target = ConcreteWithPath(leaf=leaf)
|
|
1197
|
+
|
|
1198
|
+
h = Holder(target=target)
|
|
1199
|
+
assert h.target is target
|
|
1200
|
+
assert h.target.leaf is leaf
|
|
1201
|
+
|
|
1202
|
+
def test_dict_input_with_discriminator_no_path(self, tmp_path: Path) -> None:
|
|
1203
|
+
"""Programmatic dict with ``__type__`` works even without any
|
|
1204
|
+
file loading (this exercises Typed's ``__new__`` discriminator
|
|
1205
|
+
path, which is independent of TypedPath but also lives in
|
|
1206
|
+
morphic; we verify the two compose)."""
|
|
1207
|
+
LeafConfig, AbstractBase, ConcreteWithPath, ConcreteWithoutPath, Holder = self._make_classes()
|
|
1208
|
+
|
|
1209
|
+
leaf_path = tmp_path / "l.json"
|
|
1210
|
+
leaf_path.write_text(json.dumps({"label": "L", "count": 5}))
|
|
1211
|
+
|
|
1212
|
+
h = Holder(
|
|
1213
|
+
target={
|
|
1214
|
+
"__type__": "with_path",
|
|
1215
|
+
"leaf": str(leaf_path), # absolute path inside an inline dict
|
|
1216
|
+
}
|
|
1217
|
+
)
|
|
1218
|
+
assert type(h.target) is ConcreteWithPath
|
|
1219
|
+
assert h.target.leaf.label == "L"
|
|
1220
|
+
assert h.target.leaf.count == 5
|
|
1221
|
+
|
|
1222
|
+
def test_three_level_nesting_with_registry_dispatch_at_top(
|
|
1223
|
+
self,
|
|
1224
|
+
tmp_path: Path,
|
|
1225
|
+
) -> None:
|
|
1226
|
+
"""Holder -> abstract Registry (resolved via __type__) -> concrete
|
|
1227
|
+
subclass with TypedPath -> nested LeafConfig with its own TypedPath
|
|
1228
|
+
field. Every level's relative paths must resolve against the
|
|
1229
|
+
right parent."""
|
|
1230
|
+
from abc import ABC
|
|
1231
|
+
|
|
1232
|
+
from morphic import Registry
|
|
1233
|
+
|
|
1234
|
+
class Bottom(Typed):
|
|
1235
|
+
value: str
|
|
1236
|
+
|
|
1237
|
+
class Middle(Typed):
|
|
1238
|
+
label: str
|
|
1239
|
+
bottom: Annotated[Bottom, TypedPath]
|
|
1240
|
+
|
|
1241
|
+
class TopBase(Typed, Registry, ABC):
|
|
1242
|
+
pass
|
|
1243
|
+
|
|
1244
|
+
class TopConcrete(TopBase):
|
|
1245
|
+
aliases = ("top_concrete",)
|
|
1246
|
+
middle: Annotated[Middle, TypedPath]
|
|
1247
|
+
|
|
1248
|
+
class Holder3(Typed):
|
|
1249
|
+
top: Annotated[TopBase, TypedPath]
|
|
1250
|
+
|
|
1251
|
+
(tmp_path / "top").mkdir()
|
|
1252
|
+
(tmp_path / "middle").mkdir()
|
|
1253
|
+
(tmp_path / "bottom").mkdir()
|
|
1254
|
+
|
|
1255
|
+
(tmp_path / "bottom" / "bottom.json").write_text(json.dumps({"value": "leaf"}))
|
|
1256
|
+
(tmp_path / "middle" / "middle.json").write_text(
|
|
1257
|
+
json.dumps(
|
|
1258
|
+
{
|
|
1259
|
+
"label": "middle-label",
|
|
1260
|
+
"bottom": "../bottom/bottom.json",
|
|
1261
|
+
}
|
|
1262
|
+
)
|
|
1263
|
+
)
|
|
1264
|
+
(tmp_path / "top" / "top.json").write_text(
|
|
1265
|
+
json.dumps(
|
|
1266
|
+
{
|
|
1267
|
+
"__type__": "top_concrete",
|
|
1268
|
+
"middle": "../middle/middle.json",
|
|
1269
|
+
}
|
|
1270
|
+
)
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
h = Holder3(top=str(tmp_path / "top" / "top.json"))
|
|
1274
|
+
assert type(h.top) is TopConcrete
|
|
1275
|
+
assert h.top.middle.label == "middle-label"
|
|
1276
|
+
assert h.top.middle.bottom.value == "leaf"
|
|
1277
|
+
|
|
1278
|
+
def test_yaml_with_registry_discriminator(self, tmp_path: Path) -> None:
|
|
1279
|
+
"""The discriminator dispatch also works for YAML files."""
|
|
1280
|
+
yaml = pytest.importorskip("yaml")
|
|
1281
|
+
|
|
1282
|
+
LeafConfig, AbstractBase, ConcreteWithPath, ConcreteWithoutPath, Holder = self._make_classes()
|
|
1283
|
+
|
|
1284
|
+
(tmp_path / "leaf").mkdir()
|
|
1285
|
+
with open(tmp_path / "leaf" / "leaf.yaml", "w") as f:
|
|
1286
|
+
yaml.safe_dump({"label": "yaml-leaf", "count": 11}, f)
|
|
1287
|
+
with open(tmp_path / "outer.yaml", "w") as f:
|
|
1288
|
+
yaml.safe_dump(
|
|
1289
|
+
{
|
|
1290
|
+
"__type__": "with_path",
|
|
1291
|
+
"leaf": "leaf/leaf.yaml",
|
|
1292
|
+
},
|
|
1293
|
+
f,
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
h = Holder(target=str(tmp_path / "outer.yaml"))
|
|
1297
|
+
assert type(h.target) is ConcreteWithPath
|
|
1298
|
+
assert h.target.leaf.label == "yaml-leaf"
|
|
1299
|
+
assert h.target.leaf.count == 11
|
|
1300
|
+
|
|
1301
|
+
def test_cli_with_registry_discriminator_in_loaded_file(self, tmp_path: Path) -> None:
|
|
1302
|
+
"""End-to-end: CLI loads a file whose content has ``__type__``
|
|
1303
|
+
plus a relative path on a subclass-only TypedPath field."""
|
|
1304
|
+
LeafConfig, AbstractBase, ConcreteWithPath, ConcreteWithoutPath, Holder = self._make_classes()
|
|
1305
|
+
|
|
1306
|
+
(tmp_path / "outer").mkdir()
|
|
1307
|
+
(tmp_path / "leaf").mkdir()
|
|
1308
|
+
(tmp_path / "leaf" / "leaf.json").write_text(json.dumps({"label": "from-cli"}))
|
|
1309
|
+
(tmp_path / "outer" / "with.json").write_text(
|
|
1310
|
+
json.dumps(
|
|
1311
|
+
{
|
|
1312
|
+
"__type__": "with_path",
|
|
1313
|
+
"leaf": "../leaf/leaf.json",
|
|
1314
|
+
}
|
|
1315
|
+
)
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
h = Holder.parse_cli_args(
|
|
1319
|
+
[
|
|
1320
|
+
"--target",
|
|
1321
|
+
str(tmp_path / "outer" / "with.json"),
|
|
1322
|
+
]
|
|
1323
|
+
)
|
|
1324
|
+
assert type(h.target) is ConcreteWithPath
|
|
1325
|
+
assert h.target.leaf.label == "from-cli"
|
|
1326
|
+
|
|
1327
|
+
def test_non_registry_inner_type_unaffected(self, tmp_path: Path) -> None:
|
|
1328
|
+
"""Plain ``Annotated[ConcreteTyped, TypedPath]`` (no Registry) is
|
|
1329
|
+
unaffected by the dispatch logic — the fix only kicks in when
|
|
1330
|
+
``cls`` is a Registry subclass.
|
|
1331
|
+
|
|
1332
|
+
``__type__`` is a morphic-reserved sentinel; ``Typed.__init__``
|
|
1333
|
+
silently pops it from any kwargs dict (whether or not the
|
|
1334
|
+
target class is a Registry subclass). So a non-Registry Typed
|
|
1335
|
+
with ``__type__`` in its loaded JSON is constructed normally
|
|
1336
|
+
with the discriminator key discarded.
|
|
1337
|
+
|
|
1338
|
+
The important property this test pins: the fix does NOT call
|
|
1339
|
+
``cls.get_subclass(...)`` on a non-Registry class (which would
|
|
1340
|
+
raise ``AttributeError: get_subclass``).
|
|
1341
|
+
"""
|
|
1342
|
+
|
|
1343
|
+
class Plain(Typed):
|
|
1344
|
+
name: str
|
|
1345
|
+
|
|
1346
|
+
class PlainHolder(Typed):
|
|
1347
|
+
x: Annotated[Plain, TypedPath]
|
|
1348
|
+
|
|
1349
|
+
path = tmp_path / "x.json"
|
|
1350
|
+
path.write_text(json.dumps({"__type__": "ignored", "name": "x"}))
|
|
1351
|
+
|
|
1352
|
+
## Should construct cleanly: __type__ is silently popped, name
|
|
1353
|
+
## becomes "x". The fix's `issubclass(cls, Registry)` guard
|
|
1354
|
+
## prevents an AttributeError on get_subclass.
|
|
1355
|
+
h = PlainHolder(x=str(path))
|
|
1356
|
+
assert h.x.name == "x"
|
|
1357
|
+
assert isinstance(h.x, Plain)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|