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.
Files changed (52) hide show
  1. {morphic-0.2.3 → morphic-0.2.4}/PKG-INFO +1 -1
  2. {morphic-0.2.3 → morphic-0.2.4}/src/morphic/typed.py +41 -0
  3. {morphic-0.2.3 → morphic-0.2.4}/tests/test_typed_typed_path.py +354 -3
  4. {morphic-0.2.3 → morphic-0.2.4}/.cursor/rules/morphic-standards.mdc +0 -0
  5. {morphic-0.2.3 → morphic-0.2.4}/.cursor/rules/typed-registry-examples.mdc +0 -0
  6. {morphic-0.2.3 → morphic-0.2.4}/.github/workflows/docs.yml +0 -0
  7. {morphic-0.2.3 → morphic-0.2.4}/.github/workflows/linting.yml +0 -0
  8. {morphic-0.2.3 → morphic-0.2.4}/.github/workflows/release.yml +0 -0
  9. {morphic-0.2.3 → morphic-0.2.4}/.github/workflows/tests.yml +0 -0
  10. {morphic-0.2.3 → morphic-0.2.4}/.gitignore +0 -0
  11. {morphic-0.2.3 → morphic-0.2.4}/LICENSE +0 -0
  12. {morphic-0.2.3 → morphic-0.2.4}/README.md +0 -0
  13. {morphic-0.2.3 → morphic-0.2.4}/docs/api/autoenum.md +0 -0
  14. {morphic-0.2.3 → morphic-0.2.4}/docs/api/index.md +0 -0
  15. {morphic-0.2.3 → morphic-0.2.4}/docs/api/registry.md +0 -0
  16. {morphic-0.2.3 → morphic-0.2.4}/docs/api/string.md +0 -0
  17. {morphic-0.2.3 → morphic-0.2.4}/docs/api/typed.md +0 -0
  18. {morphic-0.2.3 → morphic-0.2.4}/docs/examples.md +0 -0
  19. {morphic-0.2.3 → morphic-0.2.4}/docs/index.md +0 -0
  20. {morphic-0.2.3 → morphic-0.2.4}/docs/installation.md +0 -0
  21. {morphic-0.2.3 → morphic-0.2.4}/docs/stylesheets/extra.css +0 -0
  22. {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/autoenum.md +0 -0
  23. {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/getting-started.md +0 -0
  24. {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/registry.md +0 -0
  25. {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/string.md +0 -0
  26. {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/typed-cli.md +0 -0
  27. {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/typed-registry-integration.md +0 -0
  28. {morphic-0.2.3 → morphic-0.2.4}/docs/user-guide/typed.md +0 -0
  29. {morphic-0.2.3 → morphic-0.2.4}/mkdocs.yml +0 -0
  30. {morphic-0.2.3 → morphic-0.2.4}/pyproject.toml +0 -0
  31. {morphic-0.2.3 → morphic-0.2.4}/src/morphic/__init__.py +0 -0
  32. {morphic-0.2.3 → morphic-0.2.4}/src/morphic/autoenum.py +0 -0
  33. {morphic-0.2.3 → morphic-0.2.4}/src/morphic/classproperty.py +0 -0
  34. {morphic-0.2.3 → morphic-0.2.4}/src/morphic/function.py +0 -0
  35. {morphic-0.2.3 → morphic-0.2.4}/src/morphic/imports.py +0 -0
  36. {morphic-0.2.3 → morphic-0.2.4}/src/morphic/registry.py +0 -0
  37. {morphic-0.2.3 → morphic-0.2.4}/src/morphic/string.py +0 -0
  38. {morphic-0.2.3 → morphic-0.2.4}/src/morphic/string_data.py +0 -0
  39. {morphic-0.2.3 → morphic-0.2.4}/src/morphic/structs.py +0 -0
  40. {morphic-0.2.3 → morphic-0.2.4}/tests/__init__.py +0 -0
  41. {morphic-0.2.3 → morphic-0.2.4}/tests/test_autoenum.py +0 -0
  42. {morphic-0.2.3 → morphic-0.2.4}/tests/test_classproperty.py +0 -0
  43. {morphic-0.2.3 → morphic-0.2.4}/tests/test_function.py +0 -0
  44. {morphic-0.2.3 → morphic-0.2.4}/tests/test_imports.py +0 -0
  45. {morphic-0.2.3 → morphic-0.2.4}/tests/test_registry.py +0 -0
  46. {morphic-0.2.3 → morphic-0.2.4}/tests/test_string.py +0 -0
  47. {morphic-0.2.3 → morphic-0.2.4}/tests/test_structs.py +0 -0
  48. {morphic-0.2.3 → morphic-0.2.4}/tests/test_typed.py +0 -0
  49. {morphic-0.2.3 → morphic-0.2.4}/tests/test_typed_basesettings.py +0 -0
  50. {morphic-0.2.3 → morphic-0.2.4}/tests/test_typed_cli_native_edge_cases.py +0 -0
  51. {morphic-0.2.3 → morphic-0.2.4}/tests/test_typed_registry_cli_dispatch.py +0 -0
  52. {morphic-0.2.3 → morphic-0.2.4}/tests/test_typed_registry_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphic
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Dynamic Python utilities for class registration, creation, and type checking
5
5
  Author-email: Abhishek Divekar <adivekar@utexas.edu>
6
6
  License-File: LICENSE
@@ -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