morphic 0.2.2__tar.gz → 0.2.3__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.2 → morphic-0.2.3}/PKG-INFO +1 -1
  2. {morphic-0.2.2 → morphic-0.2.3}/docs/user-guide/typed-cli.md +167 -0
  3. {morphic-0.2.2 → morphic-0.2.3}/docs/user-guide/typed.md +194 -0
  4. {morphic-0.2.2 → morphic-0.2.3}/src/morphic/__init__.py +2 -0
  5. {morphic-0.2.2 → morphic-0.2.3}/src/morphic/typed.py +465 -0
  6. morphic-0.2.3/tests/test_typed_typed_path.py +1006 -0
  7. {morphic-0.2.2 → morphic-0.2.3}/.cursor/rules/morphic-standards.mdc +0 -0
  8. {morphic-0.2.2 → morphic-0.2.3}/.cursor/rules/typed-registry-examples.mdc +0 -0
  9. {morphic-0.2.2 → morphic-0.2.3}/.github/workflows/docs.yml +0 -0
  10. {morphic-0.2.2 → morphic-0.2.3}/.github/workflows/linting.yml +0 -0
  11. {morphic-0.2.2 → morphic-0.2.3}/.github/workflows/release.yml +0 -0
  12. {morphic-0.2.2 → morphic-0.2.3}/.github/workflows/tests.yml +0 -0
  13. {morphic-0.2.2 → morphic-0.2.3}/.gitignore +0 -0
  14. {morphic-0.2.2 → morphic-0.2.3}/LICENSE +0 -0
  15. {morphic-0.2.2 → morphic-0.2.3}/README.md +0 -0
  16. {morphic-0.2.2 → morphic-0.2.3}/docs/api/autoenum.md +0 -0
  17. {morphic-0.2.2 → morphic-0.2.3}/docs/api/index.md +0 -0
  18. {morphic-0.2.2 → morphic-0.2.3}/docs/api/registry.md +0 -0
  19. {morphic-0.2.2 → morphic-0.2.3}/docs/api/string.md +0 -0
  20. {morphic-0.2.2 → morphic-0.2.3}/docs/api/typed.md +0 -0
  21. {morphic-0.2.2 → morphic-0.2.3}/docs/examples.md +0 -0
  22. {morphic-0.2.2 → morphic-0.2.3}/docs/index.md +0 -0
  23. {morphic-0.2.2 → morphic-0.2.3}/docs/installation.md +0 -0
  24. {morphic-0.2.2 → morphic-0.2.3}/docs/stylesheets/extra.css +0 -0
  25. {morphic-0.2.2 → morphic-0.2.3}/docs/user-guide/autoenum.md +0 -0
  26. {morphic-0.2.2 → morphic-0.2.3}/docs/user-guide/getting-started.md +0 -0
  27. {morphic-0.2.2 → morphic-0.2.3}/docs/user-guide/registry.md +0 -0
  28. {morphic-0.2.2 → morphic-0.2.3}/docs/user-guide/string.md +0 -0
  29. {morphic-0.2.2 → morphic-0.2.3}/docs/user-guide/typed-registry-integration.md +0 -0
  30. {morphic-0.2.2 → morphic-0.2.3}/mkdocs.yml +0 -0
  31. {morphic-0.2.2 → morphic-0.2.3}/pyproject.toml +0 -0
  32. {morphic-0.2.2 → morphic-0.2.3}/src/morphic/autoenum.py +0 -0
  33. {morphic-0.2.2 → morphic-0.2.3}/src/morphic/classproperty.py +0 -0
  34. {morphic-0.2.2 → morphic-0.2.3}/src/morphic/function.py +0 -0
  35. {morphic-0.2.2 → morphic-0.2.3}/src/morphic/imports.py +0 -0
  36. {morphic-0.2.2 → morphic-0.2.3}/src/morphic/registry.py +0 -0
  37. {morphic-0.2.2 → morphic-0.2.3}/src/morphic/string.py +0 -0
  38. {morphic-0.2.2 → morphic-0.2.3}/src/morphic/string_data.py +0 -0
  39. {morphic-0.2.2 → morphic-0.2.3}/src/morphic/structs.py +0 -0
  40. {morphic-0.2.2 → morphic-0.2.3}/tests/__init__.py +0 -0
  41. {morphic-0.2.2 → morphic-0.2.3}/tests/test_autoenum.py +0 -0
  42. {morphic-0.2.2 → morphic-0.2.3}/tests/test_classproperty.py +0 -0
  43. {morphic-0.2.2 → morphic-0.2.3}/tests/test_function.py +0 -0
  44. {morphic-0.2.2 → morphic-0.2.3}/tests/test_imports.py +0 -0
  45. {morphic-0.2.2 → morphic-0.2.3}/tests/test_registry.py +0 -0
  46. {morphic-0.2.2 → morphic-0.2.3}/tests/test_string.py +0 -0
  47. {morphic-0.2.2 → morphic-0.2.3}/tests/test_structs.py +0 -0
  48. {morphic-0.2.2 → morphic-0.2.3}/tests/test_typed.py +0 -0
  49. {morphic-0.2.2 → morphic-0.2.3}/tests/test_typed_basesettings.py +0 -0
  50. {morphic-0.2.2 → morphic-0.2.3}/tests/test_typed_cli_native_edge_cases.py +0 -0
  51. {morphic-0.2.2 → morphic-0.2.3}/tests/test_typed_registry_cli_dispatch.py +0 -0
  52. {morphic-0.2.2 → morphic-0.2.3}/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.2
3
+ Version: 0.2.3
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
@@ -45,6 +45,8 @@ app = App(_cli_parse_args=["--name", "alice", "--seed", "7"])
45
45
  | Repeated flag | `--name a --name b` | Last one wins |
46
46
  | Registry discriminator | `--backend.__type__ http` | Picks the concrete subclass for a `Typed + Registry` field; use [`parse_cli_args`](#pattern-registry-dispatch-with-__type__-discriminator) |
47
47
  | Discriminator inside inline JSON | `--backend '{"__type__":"http",...}'` | Same dispatch via inline JSON |
48
+ | Path to JSON / YAML file | `--llm configs/llm/qwen.json` | For fields annotated `Annotated[X, TypedPath]`; see [TypedPath fields and the CLI](#typedpath-fields-and-the-cli) |
49
+ | Path-loaded file + deep override | `--llm path.json --llm.model X` | File sets baseline, deep flag patches one leaf |
48
50
 
49
51
  ## Quick reference: what does NOT work by default
50
52
 
@@ -1005,6 +1007,171 @@ Fix: either
1005
1007
  1. Split into per-field flags: `--infra '{...}' --seed 42`.
1006
1008
  2. Use the [JSON-File config pattern](#pattern-json-file-config-with-cli-overrides) which loads root-level JSON properly.
1007
1009
 
1010
+ ## TypedPath fields and the CLI
1011
+
1012
+ `TypedPath` is a feature of `Typed` itself — it makes any field accept a path to a JSON / YAML file in addition to inline values. See [Typed user guide → Loading Typed fields from JSON / YAML files](typed.md#loading-typed-fields-from-json--yaml-files-typedpath) for the full feature description (programmatic usage, dict input, error semantics, identity preservation, optional fields, edge cases).
1013
+
1014
+ This section covers the CLI-specific behaviors: how path-loaded fields interact with `parse_cli_args`, deep overrides, and pydantic-settings.
1015
+
1016
+ ### Annotation reminder
1017
+
1018
+ ```python
1019
+ from typing import Annotated, Optional
1020
+ from morphic import Typed, TypedPath
1021
+
1022
+ class JudgeConfig(Typed):
1023
+ llm: Annotated[LLMConfig, TypedPath]
1024
+ infra: Annotated[InfraConfig, TypedPath]
1025
+ refusal_classifier: Annotated[Optional[JudgeConfig], TypedPath] = None
1026
+ ```
1027
+
1028
+ `TypedPath` is a singleton — bare, no parens. `Annotated[X, TypedPath()]` (with parens) also works since the call returns the same singleton.
1029
+
1030
+ ### CLI usage
1031
+
1032
+ `parse_cli_args` understands TypedPath-annotated fields and accepts path strings as values:
1033
+
1034
+ ```bash
1035
+ # Path to JSON file:
1036
+ python script.py --llm configs/llm/qwen.json
1037
+
1038
+ # Path to YAML file (pyyaml must be installed):
1039
+ python script.py --llm configs/llm/qwen.yaml
1040
+
1041
+ # Equals form:
1042
+ python script.py --llm=configs/llm/qwen.json
1043
+
1044
+ # Inline JSON instead of a path (no .json/.yaml suffix on the value):
1045
+ python script.py --llm '{"model":"qwen","infra":{"mode":"Ray"}}'
1046
+ ```
1047
+
1048
+ The script:
1049
+
1050
+ ```python
1051
+ class JudgeConfig(Typed):
1052
+ llm: Annotated[LLMConfig, TypedPath]
1053
+
1054
+ if __name__ == "__main__":
1055
+ config = JudgeConfig.parse_cli_args()
1056
+ ```
1057
+
1058
+ ### CLI deep override on a path-loaded field
1059
+
1060
+ This is the killer feature: load a base config from disk, then patch one or more leaves on the command line:
1061
+
1062
+ ```bash
1063
+ python script.py \
1064
+ --llm configs/llm/qwen.json \
1065
+ --llm.model qwen-large \
1066
+ --llm.infra.address ray://prod:10001
1067
+ ```
1068
+
1069
+ The flow:
1070
+
1071
+ 1. `parse_cli_args` scans argv, sees `--llm <path.json>`, loads the file, and replaces the value with the inline-JSON-encoded contents.
1072
+ 2. Pydantic-settings parses the rewritten argv. The CLI source emits `{"llm": {<contents>}}`.
1073
+ 3. The deep-override flags `--llm.model qwen-large` and `--llm.infra.address ray://...` produce `{"llm": {"model": "qwen-large"}}` and `{"llm": {"infra": {"address": "..."}}}`.
1074
+ 4. Pydantic-settings' `deep_update` merges these onto the loaded baseline. The override wins on every leaf key it explicitly sets; everything else stays from the file.
1075
+
1076
+ The merge happens at the dict level, BEFORE the inner Typed is validated, so a `--llm.<sub>` flag works regardless of whether the sub-key was present in the JSON file.
1077
+
1078
+ ### Why this works (without a custom CLI source)
1079
+
1080
+ Pydantic-settings' CLI source treats nested-model fields as "complex" and unconditionally calls `json.loads` on their values. A path string like `configs/foo.json` isn't valid JSON, so without intervention you'd get `SettingsError: error parsing value for field "llm" from source "CliSettingsSource"`.
1081
+
1082
+ morphic handles this by preprocessing argv inside `parse_cli_args`: it walks the model's TypedPath-annotated fields, finds matching `--field <value>` (or `--field=<value>`) tokens where the value looks like a path, loads the file, and replaces the argv token with the JSON-encoded file contents. Pydantic-settings then handles everything from there using its native code paths — no custom `CliSettingsSource` subclass, no monkeypatching.
1083
+
1084
+ ### Path resolution rules apply on the CLI too
1085
+
1086
+ The path resolution rules from [the Typed guide](typed.md#path-resolution-rules) apply to CLI input identically:
1087
+
1088
+ ```bash
1089
+ # Absolute path: used as-is.
1090
+ python script.py --llm /abs/path/qwen.json
1091
+
1092
+ # Relative path: resolved against the current working directory.
1093
+ python script.py --llm configs/llm/qwen.json # cwd-relative
1094
+
1095
+ # Inside the loaded file, relative paths are resolved against THAT file's directory:
1096
+ # configs/llm/qwen.json: {"infra": "../infra/sync.json"}
1097
+ # → resolved against configs/llm/, NOT cwd
1098
+ ```
1099
+
1100
+ This makes multi-level config trees work without any "config root" environment variable or custom resolution logic.
1101
+
1102
+ ### Multiple TypedPath fields in one CLI invocation
1103
+
1104
+ ```python
1105
+ class App(Typed):
1106
+ target: Annotated[LLMConfig, TypedPath]
1107
+ judge: Annotated[JudgeConfig, TypedPath]
1108
+ refusal_classifier: Annotated[Optional[JudgeConfig], TypedPath] = None
1109
+ ```
1110
+
1111
+ ```bash
1112
+ python train.py \
1113
+ --target configs/llm/qwen-2-5-7b.json \
1114
+ --judge configs/judges/wildguard.json \
1115
+ --judge.llm.infra.address ray://prod:10001 \
1116
+ --refusal-classifier configs/judges/promptguard.json
1117
+ ```
1118
+
1119
+ Each TypedPath field is loaded independently. Deep overrides target each one separately.
1120
+
1121
+ ### Optional TypedPath fields on the CLI
1122
+
1123
+ `Annotated[Optional[X], TypedPath] = None` works on the CLI:
1124
+
1125
+ ```bash
1126
+ # Field omitted → stays None.
1127
+ python script.py --target configs/llm/qwen.json
1128
+
1129
+ # Field explicitly set to None via inline JSON literal:
1130
+ python script.py --refusal-classifier null
1131
+
1132
+ # Field set to a path:
1133
+ python script.py --refusal-classifier configs/judges/promptguard.json
1134
+ ```
1135
+
1136
+ ### CLI errors
1137
+
1138
+ | Scenario | What happens |
1139
+ |---|---|
1140
+ | `--llm /missing.json` | `FileNotFoundError` with field name + resolved absolute path |
1141
+ | `--llm /broken.json` (invalid JSON) | `ValueError` with file path + JSON parse error |
1142
+ | `--llm /list.json` (top-level is a JSON array, not object) | `ValueError`: the file's top-level value must be a mapping |
1143
+ | `--llm "not-a-path"` (string with no `.json`/`.yaml`/`.yml` suffix) | Falls through to pydantic-settings' normal CLI parsing → `SettingsError` because the string isn't valid JSON for a complex field |
1144
+
1145
+ ### Composing TypedPath with discriminator dispatch
1146
+
1147
+ When a TypedPath field's inner type is a `Typed + Registry` abstract base, the loaded JSON/YAML can include a `__type__` discriminator to pick the concrete subclass. See [Typed CLI guide → Pattern: Registry dispatch](#pattern-registry-dispatch-with-__type__-discriminator) for the discriminator details.
1148
+
1149
+ ```python
1150
+ class Backend(Typed, Registry, ABC):
1151
+ name: str
1152
+
1153
+ class HttpBackend(Backend):
1154
+ aliases = ("http",)
1155
+ url: str
1156
+
1157
+ class App(Typed):
1158
+ backend: Annotated[Backend, TypedPath]
1159
+ ```
1160
+
1161
+ `configs/backends/prod.json`:
1162
+ ```json
1163
+ {
1164
+ "__type__": "http",
1165
+ "name": "prod",
1166
+ "url": "https://api.example.com"
1167
+ }
1168
+ ```
1169
+
1170
+ ```bash
1171
+ python script.py --backend configs/backends/prod.json
1172
+ # → app.backend is an HttpBackend instance
1173
+ ```
1174
+
1008
1175
  ## See also
1009
1176
 
1010
1177
  - [Typed user guide](typed.md) — the foundational guide for `Typed`, validation, lifecycle hooks, frozen models, `PrivateAttr`.
@@ -13,6 +13,7 @@ Typed is built on Pydantic's `BaseSettings` (a `BaseModel` with extra `__init__`
13
13
  - **Advanced error handling** - Enhanced error messages with detailed validation information
14
14
  - **Hierarchical type support** - Nested Typed objects, lists, and dictionaries with automatic conversion
15
15
  - **Identity preservation** - A pre-built Typed passed as a field of an outer Typed is reused, not cloned (`revalidate_instances="never"`)
16
+ - **Load from JSON / YAML files** - Annotate any field with `Annotated[X, TypedPath]` to make it accept a path to a file in addition to instances and dicts; see [Loading Typed fields from JSON / YAML files (TypedPath)](#loading-typed-fields-from-json--yaml-files-typedpath)
16
17
  - **CLI integration** - Every Typed accepts `_cli_parse_args=...` and supports nested overrides (`--infra.ray-init.address X`); use [`Config.parse_cli_args()`](typed-cli.md#pattern-pure-native-cli) as the recommended entrypoint
17
18
  - **Registry CLI dispatch** - Typed + Registry hierarchies support `__type__` discriminator dispatch in kwargs, dict input, and CLI flags (`--backend.__type__ http`); see [Typed CLI guide](typed-cli.md#pattern-registry-dispatch-with-__type__-discriminator)
18
19
  - **Source isolation** - Environment variables and dotenv files are NOT read by default, so a field named `user` won't accidentally pick up `$USER`
@@ -350,6 +351,199 @@ except ValidationError:
350
351
  print("Assignment validation failed")
351
352
  ```
352
353
 
354
+ ## Loading Typed fields from JSON / YAML files (TypedPath)
355
+
356
+ `TypedPath` is a marker that turns any field into a "path-loadable" field. The user can pass either an inline value (instance, dict) or a path to a `.json` / `.yaml` / `.yml` file, and the file's contents are loaded, parsed, and validated as the field's type.
357
+
358
+ This works **everywhere** — programmatically, in dict inputs, in CLI flags. Although the CLI is the most common motivation (deeply-nested configs are awkward to express as one inline JSON string on the command line), the same field also accepts paths when constructed from Python code, which makes notebook workflows and tests straightforward.
359
+
360
+ ### Annotation
361
+
362
+ ```python
363
+ from typing import Annotated, Optional
364
+ from morphic import Typed, TypedPath
365
+
366
+ class JudgeConfig(Typed):
367
+ llm: Annotated[LLMConfig, TypedPath]
368
+ infra: Annotated[InfraConfig, TypedPath]
369
+ refusal_classifier: Annotated[Optional[JudgeConfig], TypedPath] = None
370
+ ```
371
+
372
+ `TypedPath` is a singleton — it does not take any arguments and you do not call it. Just write `Annotated[X, TypedPath]` (no parens). `Annotated[X, TypedPath()]` also works (the call returns the same singleton), but the bare form is preferred so users don't have to remember to instantiate it.
373
+
374
+ ### Three accepted input forms
375
+
376
+ A field annotated `Annotated[X, TypedPath]` accepts three kinds of value, in any context (programmatic, dict, CLI):
377
+
378
+ 1. **A pre-built `X` instance** — passed through unchanged, identity preserved.
379
+ 2. **A dict matching `X`'s schema** — validated as `X`.
380
+ 3. **A path string or `Path` object** ending in `.json`, `.yaml`, or `.yml` — the file is read, parsed, and validated as `X`.
381
+
382
+ #### Programmatic construction
383
+
384
+ ```python
385
+ from pathlib import Path
386
+ from morphic import Typed, TypedPath
387
+ from typing import Annotated
388
+
389
+ class LLMConfig(Typed):
390
+ model: str
391
+ timeout: float = 30.0
392
+
393
+ class App(Typed):
394
+ llm: Annotated[LLMConfig, TypedPath]
395
+
396
+ # Form 1: pre-built instance — identity preserved
397
+ llm = LLMConfig(model="qwen", timeout=60.0)
398
+ app = App(llm=llm)
399
+ assert app.llm is llm # same instance
400
+
401
+ # Form 2: dict
402
+ app = App(llm={"model": "qwen", "timeout": 60.0})
403
+ assert isinstance(app.llm, LLMConfig)
404
+
405
+ # Form 3a: path string (relative or absolute)
406
+ app = App(llm="configs/llm/qwen.json")
407
+
408
+ # Form 3b: pathlib.Path object
409
+ app = App(llm=Path("configs/llm/qwen.yaml"))
410
+ ```
411
+
412
+ YAML support requires `pyyaml` to be installed. If a `.yaml` / `.yml` path arrives but `pyyaml` isn't installed, you get a clear `ImportError` pointing at the missing package. JSON is always supported (Python stdlib).
413
+
414
+ #### Dict input with embedded paths
415
+
416
+ This pattern is common when constructing a Typed from JSON data that someone else parsed for you:
417
+
418
+ ```python
419
+ data = {
420
+ "llm": "configs/llm/qwen.json", # Path string inside a dict
421
+ "infra": {"mode": "Sync"}, # Inline dict for another field
422
+ }
423
+ app = App.model_validate(data)
424
+ ```
425
+
426
+ The path string for `llm` triggers file loading; the inline dict for `infra` is used as-is.
427
+
428
+ #### CLI input
429
+
430
+ The CLI just routes to the same path-loading machinery via [`Config.parse_cli_args`](typed-cli.md#pattern-pure-native-cli):
431
+
432
+ ```bash
433
+ python script.py --llm configs/llm/qwen.json --infra configs/infra/ec2-ray.json
434
+
435
+ # Or override individual leaves of the loaded file:
436
+ python script.py --llm configs/llm/qwen.json --llm.model qwen-large
437
+ ```
438
+
439
+ See [the CLI guide](typed-cli.md#typedpath-load-nested-fields-from-json--yaml-files) for full CLI semantics including deep overrides on path-loaded fields.
440
+
441
+ ### Path resolution rules
442
+
443
+ Three rules, in this order:
444
+
445
+ 1. **Absolute paths** are used as-is.
446
+ 2. **Relative paths inside a loaded file** are resolved against the directory of THAT file (the parent file currently being loaded).
447
+ 3. **Relative paths from outside any file** (programmatic / top-level CLI input) are resolved against the current working directory.
448
+
449
+ The "parent file's directory" rule (#2) makes nested config files compose without any work from the user. Consider this layout:
450
+
451
+ ```
452
+ configs/
453
+ ├── judges/
454
+ │ └── wildguard.json ──> {"llm": "../../llm/wildguard.json", "name": "wildguard"}
455
+ ├── llm/
456
+ │ └── wildguard.json ──> {"model": "...", "infra": "../infra/sync.json"}
457
+ └── infra/
458
+ └── sync.json ──> {"mode": "Sync"}
459
+ ```
460
+
461
+ When you run `JudgeConfig.parse_cli_args(["--judge", "configs/judges/wildguard.json"])`:
462
+
463
+ 1. The outer file `configs/judges/wildguard.json` is loaded.
464
+ 2. Its `"llm"` field has the relative path `"../../llm/wildguard.json"`. This is resolved against `configs/judges/`, giving `configs/llm/wildguard.json`.
465
+ 3. That file is loaded. Its `"infra"` field has the relative path `"../infra/sync.json"`, which is resolved against `configs/llm/`, giving `configs/infra/sync.json`.
466
+ 4. That file is loaded.
467
+
468
+ You don't pass any "config root" parameter; the parent-dir rule threads through all the levels automatically.
469
+
470
+ ### Optional fields
471
+
472
+ `Annotated[Optional[X], TypedPath] = None` works as expected:
473
+
474
+ ```python
475
+ class JudgeConfig(Typed):
476
+ llm: Annotated[LLMConfig, TypedPath]
477
+ refusal_classifier: Annotated[Optional[JudgeConfig], TypedPath] = None
478
+ ```
479
+
480
+ The user can omit `refusal_classifier`, pass `None`, an instance, a dict, or a path. Loading only triggers when a non-`None` value is provided.
481
+
482
+ ### Validation always runs on loaded content
483
+
484
+ Reading a file does NOT skip validation. The dict parsed from JSON/YAML goes through the same validation pipeline as any other dict input:
485
+
486
+ ```python
487
+ class LLMConfig(Typed):
488
+ model: str
489
+ timeout: float = 30.0
490
+
491
+ # A file with bad data:
492
+ # config.json: {"model": "qwen", "timeout": "not-a-number"}
493
+
494
+ app = App(llm="config.json")
495
+ # ValueError: Cannot create Pydantic instance of type 'LLMConfig' ...
496
+ # timeout: Input should be a valid number, ...
497
+ ```
498
+
499
+ The same applies to:
500
+ - Missing required fields → caught.
501
+ - Extra fields → rejected (Typed's `extra="forbid"`).
502
+ - Field type mismatches → caught.
503
+
504
+ ### Errors
505
+
506
+ | Scenario | Error type | Error message includes |
507
+ |---|---|---|
508
+ | Path doesn't exist | `FileNotFoundError` | field name, resolved absolute path, original input |
509
+ | File is not a regular file (e.g., a directory named `foo.json`) | `ValueError` | field name, resolved path |
510
+ | Corrupt JSON | `ValueError` | field name, file path, JSON parse error |
511
+ | Corrupt YAML | `ValueError` | field name, file path, YAML parse error |
512
+ | YAML file but `pyyaml` not installed | `ImportError` | install instruction |
513
+ | File parses but top-level is not a JSON object / YAML mapping | `ValueError` | field name, actual type |
514
+ | Loaded content fails inner type validation | `ValueError` (Pydantic `ValidationError` wrapped) | full field path, offending field, expected type |
515
+
516
+ ### What `TypedPath` is NOT
517
+
518
+ These are deliberate non-features, not oversights:
519
+
520
+ - **NOT a runtime type.** After validation, no field on any Typed instance has type `TypedPath`. The annotation is consumed during validation. Reading `judge.llm` always returns an `LLMConfig` instance.
521
+ - **NOT lazy.** The file is read and parsed at construction time. If the file is missing or malformed, construction fails immediately.
522
+ - **NOT cached.** Each construction reads the file fresh. If you load the same file twice, you get two distinct (but equal) instances.
523
+ - **NOT for collection elements.** `List[Annotated[X, TypedPath]]` does NOT load each list element from a path. TypedPath only triggers on single-Typed fields. If you need a list of file-loadable items, load them in your code and pass the list of instances.
524
+ - **NOT a global validator.** The path-loading machinery only runs on classes that have at least one TypedPath-annotated field. Pure Typeds with no TypedPath fields pay zero overhead per construction.
525
+ - **NOT for remote URIs.** Only local filesystem paths are supported (no `s3://`, no `https://`). Add fetching code yourself if you need to download a config.
526
+
527
+ ### Mixing TypedPath with other Typed features
528
+
529
+ `TypedPath` composes cleanly with everything else in Typed:
530
+
531
+ - **Identity preservation**: still applies for the instance branch (Form 1). When the user passes a pre-built instance, `o.llm is the_instance`. The path-loading branch always produces a fresh instance (the file system is the source of truth).
532
+ - **`post_initialize`**: runs once on every instance produced, including those loaded from files. The framework's [#12876 idempotency marker](#identity-preservation-and-pre-built-instances) ensures heavy `post_initialize` work runs exactly once per instance.
533
+ - **Registry dispatch**: `Annotated[BackendBase, TypedPath]` works alongside `__type__` discriminators. The loaded JSON/YAML can include a `__type__` key to select the concrete subclass. See [Typed CLI guide → Pattern: Registry dispatch](typed-cli.md#pattern-registry-dispatch-with-__type__-discriminator).
534
+ - **Lifecycle hooks**: `pre_initialize` and `pre_validate` run after path loading (so they see the dict, not the path string).
535
+
536
+ ### When to use `TypedPath`
537
+
538
+ | Need | Use |
539
+ |---|---|
540
+ | Single root config from a JSON/YAML file | [JSON-File config pattern](typed-cli.md#pattern-json-file-config-with-cli-overrides) (small argparse wrapper) |
541
+ | Multiple deeply-nested config files cross-referencing each other | `TypedPath` — much cleaner than chaining the wrapper for every sub-config |
542
+ | Some fields are simple inline values, others are big nested configs | `TypedPath` on the big fields, leave the simple ones plain |
543
+ | All config comes from CLI flags / env vars | Plain Typed; no TypedPath needed |
544
+
545
+ The trojanshot use case is squarely TypedPath territory: `JudgeConfig` references an `LLMConfig` file, which references an `InfraConfig` file, and each is independently editable. The `Annotated[X, TypedPath]` annotation models this cleanly without any custom argv preprocessor or config-loader scaffolding.
546
+
353
547
  ## Default Value Validation and Conversion
354
548
 
355
549
  Pydantic validates and converts default values automatically, ensuring type safety and preventing common errors.
@@ -91,6 +91,7 @@ from .typed import (
91
91
  TYPED_REGISTRY_DISCRIMINATOR_KEY,
92
92
  MutableTyped,
93
93
  Typed,
94
+ TypedPath,
94
95
  ValidationError,
95
96
  validate,
96
97
  )
@@ -104,6 +105,7 @@ __all__ = [
104
105
  "Typed",
105
106
  "MutableTyped",
106
107
  "TYPED_REGISTRY_DISCRIMINATOR_KEY",
108
+ "TypedPath",
107
109
  "validate",
108
110
  "ValidationError",
109
111
  "classproperty",