didactic 0.3.0__tar.gz → 0.3.2__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 (49) hide show
  1. {didactic-0.3.0 → didactic-0.3.2}/PKG-INFO +2 -2
  2. {didactic-0.3.0 → didactic-0.3.2}/pyproject.toml +2 -2
  3. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/_self_describing.py +14 -7
  4. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/api.py +1 -2
  5. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/axioms/_axiom_enforcement.py +0 -1
  6. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/cli/_cli.py +9 -4
  7. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/codegen/_json_schema.py +8 -3
  8. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/codegen/_write.py +2 -8
  9. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/codegen/source.py +5 -7
  10. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/fields/_computed.py +0 -1
  11. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/fields/_derived.py +12 -6
  12. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/fields/_fields.py +18 -11
  13. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/fields/_unions.py +15 -9
  14. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/fields/_validators.py +8 -5
  15. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/lenses/_dependent_lens.py +7 -7
  16. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/lenses/_lens.py +11 -9
  17. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/lenses/_testing.py +2 -2
  18. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/migrations/_diff.py +22 -4
  19. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/migrations/_fingerprint.py +10 -8
  20. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/migrations/_synthesis.py +4 -7
  21. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/models/_config.py +0 -1
  22. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/models/_meta.py +35 -20
  23. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/models/_model.py +8 -13
  24. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/models/_root.py +6 -6
  25. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/theory/_theory.py +8 -49
  26. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/types/_types.py +65 -31
  27. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/types/_typing.py +2 -11
  28. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/vcs/_backref.py +8 -7
  29. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/vcs/_repo.py +13 -11
  30. {didactic-0.3.0 → didactic-0.3.2}/.gitignore +0 -0
  31. {didactic-0.3.0 → didactic-0.3.2}/README.md +0 -0
  32. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/axioms/__init__.py +0 -0
  33. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/axioms/_axioms.py +0 -0
  34. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/cli/__init__.py +0 -0
  35. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/codegen/__init__.py +0 -0
  36. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/codegen/_emitter.py +0 -0
  37. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/codegen/io.py +0 -0
  38. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/fields/__init__.py +0 -0
  39. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/fields/_refs.py +0 -0
  40. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/lenses/__init__.py +0 -0
  41. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/migrations/__init__.py +0 -0
  42. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/migrations/_migrations.py +0 -0
  43. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/models/__init__.py +0 -0
  44. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/models/_storage.py +0 -0
  45. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/py.typed +0 -0
  46. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/theory/__init__.py +0 -0
  47. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/types/__init__.py +0 -0
  48. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/types/_types_lib.py +0 -0
  49. {didactic-0.3.0 → didactic-0.3.2}/src/didactic/vcs/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: didactic
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Pydantic-class API on top of panproto: GATs, lenses, and VCS.
5
5
  Project-URL: Homepage, https://github.com/panproto/didactic
6
6
  Project-URL: Repository, https://github.com/panproto/didactic
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.14
15
15
  Classifier: Typing :: Typed
16
16
  Requires-Python: >=3.14
17
17
  Requires-Dist: annotated-types>=0.7
18
- Requires-Dist: panproto>=0.43
18
+ Requires-Dist: panproto>=0.43.1
19
19
  Description-Content-Type: text/markdown
20
20
 
21
21
  # didactic
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "didactic"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "Pydantic-class API on top of panproto: GATs, lenses, and VCS."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -22,7 +22,7 @@ classifiers = [
22
22
  "Typing :: Typed",
23
23
  ]
24
24
  dependencies = [
25
- "panproto>=0.43",
25
+ "panproto>=0.43.1",
26
26
  "annotated-types>=0.7",
27
27
  ]
28
28
 
@@ -31,12 +31,9 @@ didactic.migrations._fingerprint.structural_fingerprint : the underlying address
31
31
 
32
32
  # Cross-translation between ``FieldValue`` and ``JsonValue`` shapes
33
33
  # in the schema-URI registry layer.
34
- # Tracked in panproto/didactic#1.
35
- # pyright: reportArgumentType=false, reportReturnType=false
36
-
37
34
  from __future__ import annotations
38
35
 
39
- from typing import TYPE_CHECKING
36
+ from typing import TYPE_CHECKING, cast
40
37
 
41
38
  if TYPE_CHECKING:
42
39
  from didactic.models._model import Model
@@ -67,7 +64,7 @@ def schema_uri(cls: type[Model]) -> str:
67
64
 
68
65
 
69
66
  def embed_schema_uri(instance: Model) -> JsonObject:
70
- """Return ``instance.model_dump()`` with a ``$schema`` URI prepended.
67
+ """Return the JSON-shape dump of ``instance`` with a ``$schema`` URI prepended.
71
68
 
72
69
  Parameters
73
70
  ----------
@@ -77,16 +74,23 @@ def embed_schema_uri(instance: Model) -> JsonObject:
77
74
  Returns
78
75
  -------
79
76
  dict
80
- The dump dict, with ``"$schema"`` set to the Model's
77
+ The JSON-safe dump dict, with ``"$schema"`` set to the Model's
81
78
  canonical URI as the first key.
82
79
 
83
80
  Notes
84
81
  -----
82
+ Routes through ``model_dump_json`` (not the bare ``model_dump``)
83
+ so any nested ``tuple[Embed[T], ...]`` or ``dict[str, Embed[T]]``
84
+ fields get the JSON-safe walk; the returned dict is always
85
+ serialisable with ``json.dumps``.
86
+
85
87
  A consumer that knows how to resolve ``didactic://v1/<fp>`` URIs
86
88
  can fetch the Theory by fingerprint and validate the payload
87
89
  without knowing the original Python class.
88
90
  """
89
- payload = instance.model_dump()
91
+ import json # noqa: PLC0415
92
+
93
+ payload = cast("JsonObject", json.loads(instance.model_dump_json()))
90
94
  return {"$schema": schema_uri(type(instance)), **payload}
91
95
 
92
96
 
@@ -188,6 +192,9 @@ def validate_with_uri_lookup(
188
192
  raise KeyError(msg)
189
193
 
190
194
  uri = payload["$schema"]
195
+ if not isinstance(uri, str):
196
+ msg = "validate_with_uri_lookup: $schema value must be a string"
197
+ raise TypeError(msg)
191
198
  cls = registry.lookup(uri)
192
199
  if cls is None:
193
200
  msg = f"validate_with_uri_lookup: no registered model for URI {uri!r}"
@@ -68,14 +68,13 @@ from didactic.types import _types_lib as types
68
68
  from didactic.vcs._backref import ModelPool, resolve_backrefs
69
69
  from didactic.vcs._repo import Repository
70
70
 
71
- __version__ = "0.3.0"
71
+ __version__ = "0.3.2"
72
72
 
73
73
  #: Conventional namespace for lens utilities (`dx.lens.identity(...)`,
74
74
  #: `dx.lens.Lens`, etc.). The ``lens`` name doubles as a decorator
75
75
  #: (``@dx.lens(A, B)``) and as a module-style namespace. The attributes
76
76
  #: are bound by ``_LensNamespace.__init__`` in :mod:`didactic.lenses._lens`.
77
77
 
78
-
79
78
  __all__ = [
80
79
  "DEFAULT_CONFIG",
81
80
  "Axiom",
@@ -26,7 +26,6 @@ if TYPE_CHECKING:
26
26
  from didactic.axioms._axioms import Axiom
27
27
  from didactic.types._typing import FieldValue, JsonValue
28
28
 
29
-
30
29
  # A panproto Expr ``to_dict()`` AST node is one of any JsonValue-shaped
31
30
  # payload — at the leaves of dispatch we narrow with ``match`` /
32
31
  # ``isinstance``. Using ``JsonValue`` (rather than the narrower
@@ -2,7 +2,6 @@
2
2
  # ``.breaking`` shape is a ``JsonValue`` union; pyright can't narrow
3
3
  # the iteration target without per-branch ``isinstance``. Tracked in
4
4
  # panproto/didactic#1.
5
- # pyright: reportGeneralTypeIssues=false, reportOptionalIterable=false, reportUnknownVariableType=false
6
5
  """``didactic`` CLI front-end.
7
6
 
8
7
  Subcommands
@@ -117,6 +116,11 @@ def _resolve_model(spec: str) -> type[Model]:
117
116
  return cls
118
117
 
119
118
 
119
+ # Public re-export so tests can introspect the resolver without tripping
120
+ # pyright's reportPrivateUsage.
121
+ resolve_model = _resolve_model
122
+
123
+
120
124
  def _cmd_schema_show(args: argparse.Namespace) -> int:
121
125
  cls = _resolve_model(args.model)
122
126
  from didactic.migrations._fingerprint import structural_fingerprint # noqa: PLC0415
@@ -190,8 +194,10 @@ def _cmd_check_breaking(args: argparse.Namespace) -> int:
190
194
  print(f"compatible: {args.old} -> {args.new}")
191
195
  return 0
192
196
  print(f"BREAKING: {args.old} -> {args.new}")
193
- for change in report.get("breaking", []):
194
- print(f" {change}")
197
+ breaking = report.get("breaking")
198
+ if isinstance(breaking, list):
199
+ for change in breaking:
200
+ print(f" {change}")
195
201
  return 2
196
202
 
197
203
 
@@ -235,7 +241,6 @@ _SUBCOMMANDS = {
235
241
  "version": _cmd_version,
236
242
  }
237
243
 
238
-
239
244
  __all__ = [
240
245
  "main",
241
246
  ]
@@ -1,7 +1,6 @@
1
1
  # ``annotated_metadata`` lives on ``spec.extras`` as ``Opaque`` and
2
2
  # the per-marker iteration takes the marker as an opaque value to
3
3
  # duck-type. Tracked in panproto/didactic#1.
4
- # pyright: reportUnknownVariableType=false, reportUnknownArgumentType=false
5
4
  """JSON Schema (Draft 2020-12) generation from a Model class.
6
5
 
7
6
  The single user-facing entry point is
@@ -147,7 +146,13 @@ def _schema_for_field(spec: FieldSpec) -> JsonSchemaProperty:
147
146
  annotation = spec.annotation
148
147
  out: dict[str, JsonValue] = {}
149
148
 
150
- type_str, format_str = _PRIMITIVE_TYPE_MAP.get(annotation, ("string", None))
149
+ # The map is keyed on bare ``type`` instances (``str``, ``int``,
150
+ # etc.); ``TypeVar`` / ``ForwardRef`` annotations fall through to
151
+ # the ``("string", None)`` default.
152
+ if isinstance(annotation, type):
153
+ type_str, format_str = _PRIMITIVE_TYPE_MAP.get(annotation, ("string", None))
154
+ else:
155
+ type_str, format_str = "string", None
151
156
  out["type"] = type_str
152
157
  if format_str is not None:
153
158
  out["format"] = format_str
@@ -174,7 +179,7 @@ def _schema_for_field(spec: FieldSpec) -> JsonSchemaProperty:
174
179
  # as ``Opaque`` (a marker Protocol); we narrow with isinstance.
175
180
  metadata = extras.get("annotated_metadata", ()) or ()
176
181
  if isinstance(metadata, (tuple, list)):
177
- for entry in metadata:
182
+ for entry in cast("tuple[Opaque, ...] | list[Opaque]", metadata):
178
183
  _apply_annotated_constraint(out, entry)
179
184
 
180
185
  return cast("JsonSchemaProperty", out)
@@ -17,9 +17,6 @@ Examples
17
17
 
18
18
  # Defensive ``isinstance(payload, bytes)`` on a parameter pyright
19
19
  # narrows to ``bytes`` already; runtime callers may bypass.
20
- # Tracked in panproto/didactic#1.
21
- # pyright: reportUnnecessaryIsInstance=false
22
-
23
20
  from __future__ import annotations
24
21
 
25
22
  from pathlib import Path
@@ -96,11 +93,8 @@ def write(
96
93
  ext=ext,
97
94
  )
98
95
  path = out_path / filename_str
99
- payload = model.emit_as(target) # type: ignore[attr-defined]
100
- payload_bytes = (
101
- payload if isinstance(payload, bytes) else str(payload).encode("utf-8")
102
- )
103
- path.write_bytes(payload_bytes)
96
+ payload = model.emit_as(target)
97
+ path.write_bytes(payload)
104
98
  written[f"{target}/{model.__name__}"] = path.resolve()
105
99
  return written
106
100
 
@@ -29,14 +29,9 @@ didactic.codegen.io : instance-level codecs.
29
29
  didactic.Model.emit_as : the unified entry point.
30
30
  """
31
31
 
32
- # ``schema: object`` (panproto handle carve-out) handed back to
33
- # panproto's ``emit(schema: Schema)``.
34
- # Tracked in panproto/didactic#1.
35
- # pyright: reportArgumentType=false
36
-
37
32
  from __future__ import annotations
38
33
 
39
- from typing import TYPE_CHECKING
34
+ from typing import TYPE_CHECKING, cast
40
35
 
41
36
  if TYPE_CHECKING:
42
37
  from collections.abc import Callable
@@ -112,7 +107,10 @@ def emit(schema: object, *, protocol: str) -> bytes:
112
107
  import panproto # noqa: PLC0415
113
108
 
114
109
  registry = panproto.AstParserRegistry()
115
- return registry.emit(protocol, schema)
110
+ # ``schema`` is typed ``object`` in the public signature (carve-out
111
+ # for the opaque panproto handle); the runtime contract is that it
112
+ # came from a previous ``parse`` call and is a real ``Schema``.
113
+ return registry.emit(protocol, cast("panproto.Schema", schema))
116
114
 
117
115
 
118
116
  def parse(source: bytes, *, protocol: str, file_path: str = "<source>") -> object:
@@ -35,7 +35,6 @@ if TYPE_CHECKING:
35
35
 
36
36
  from didactic.types._typing import FieldValue
37
37
 
38
-
39
38
  _COMPUTED_MARKER_ATTR = "__didactic_computed__"
40
39
 
41
40
 
@@ -47,7 +47,6 @@ didactic.computed : evaluated every read; not cached.
47
47
  # ``_`` prefix is conventional for "implementation detail of the
48
48
  # Model layer"; the derived-decorator integration is part of that
49
49
  # layer. Tracked in panproto/didactic#1.
50
- # pyright: reportPrivateUsage=false
51
50
 
52
51
  from __future__ import annotations
53
52
 
@@ -90,18 +89,25 @@ def derived(fn: Callable[..., FieldValue]) -> property:
90
89
  >>> Box(w=3, h=4).area
91
90
  12
92
91
  """
93
- fn.__didactic_derived__ = True # type: ignore[attr-defined]
92
+ # ``__didactic_derived__`` is a marker attribute consumed by
93
+ # ``derived_field_names`` to discover wrapped derived methods. The
94
+ # function-object protocol allows arbitrary attribute writes at
95
+ # runtime; pyright models ``Callable`` as opaque so we set through
96
+ # ``setattr`` to keep the boundary explicit.
97
+ setattr(fn, "__didactic_derived__", True) # noqa: B010
94
98
  name = fn.__name__
95
99
 
96
100
  def _getter(self: Model) -> FieldValue:
97
- cache = self._derived_cache
101
+ cache = cast("dict[str, FieldValue]", getattr(self, "_derived_cache")) # noqa: B009
98
102
  if name not in cache:
99
103
  cache[name] = fn(self)
100
- return cast("FieldValue", cache[name])
104
+ return cache[name]
101
105
 
102
106
  prop = property(_getter)
103
- prop.fget.__didactic_derived__ = True # type: ignore[union-attr]
104
- prop.fget.__wrapped_name__ = name # type: ignore[union-attr]
107
+ fget = prop.fget
108
+ if fget is not None:
109
+ setattr(fget, "__didactic_derived__", True) # noqa: B010
110
+ setattr(fget, "__wrapped_name__", name) # noqa: B010
105
111
  return prop
106
112
 
107
113
 
@@ -29,11 +29,14 @@ didactic.types._types : the translation primitives FieldSpec uses.
29
29
  didactic.models._meta : the metaclass that consumes FieldSpec.
30
30
  """
31
31
 
32
- # ``extras`` is the project's ``Mapping[str, Opaque]`` and pyright
33
- # doesn't carry the value-type through ``dict(extras)``.
34
- # Tracked in panproto/didactic#1.
35
- # pyright: reportUnknownVariableType=false
36
-
32
+ # ``field()`` uses the field-specifier overload pattern from
33
+ # ``CONTRIBUTING.md`` carve-out 1: typed overloads return ``T`` so
34
+ # call sites like ``email: str = dx.field(description="...")`` type-check,
35
+ # while the implementation returns ``Field``. Pyright in strict mode
36
+ # rejects this inconsistency between the unconstrained-``T`` overload
37
+ # returns and the concrete ``Field`` impl return; no structural fix
38
+ # preserves the surface ergonomics. Confined to this file.
39
+ # pyright: reportInconsistentOverload=false
37
40
  from __future__ import annotations
38
41
 
39
42
  from dataclasses import dataclass
@@ -43,8 +46,11 @@ from typing import (
43
46
  Annotated,
44
47
  Any,
45
48
  Final,
49
+ ForwardRef,
46
50
  Literal,
47
51
  Self,
52
+ TypeVar,
53
+ cast,
48
54
  get_args,
49
55
  get_origin,
50
56
  overload,
@@ -53,7 +59,7 @@ from typing import (
53
59
  if TYPE_CHECKING:
54
60
  from collections.abc import Callable, Mapping
55
61
 
56
- from didactic.types._types import TypeTranslation
62
+ from didactic.types._types import TypeForm, TypeTranslation
57
63
  from didactic.types._typing import (
58
64
  DefaultOrMissing,
59
65
  FieldValue,
@@ -110,7 +116,6 @@ MISSING: Final[_Missing] = _Missing()
110
116
  #: ``isinstance(value, MissingType)`` without crossing a private boundary.
111
117
  MissingType = _Missing
112
118
 
113
-
114
119
  # ---------------------------------------------------------------------------
115
120
  # The Field descriptor class
116
121
  # ---------------------------------------------------------------------------
@@ -235,9 +240,9 @@ def field(
235
240
  usage_mode: Literal["readwrite", "computed", "materialised"] = ...,
236
241
  extras: Mapping[str, Opaque] | None = ...,
237
242
  ) -> Any: ... # documented escape hatch for required-with-metadata fields
238
- def field( # pyright: ignore[reportInconsistentOverload]
243
+ def field(
239
244
  *,
240
- default: DefaultOrMissing = MISSING,
245
+ default: FieldValue | _Missing = MISSING,
241
246
  default_factory: Callable[[], FieldValue] | None = None,
242
247
  converter: Callable[[FieldValue], FieldValue] | None = None,
243
248
  alias: str | None = None,
@@ -370,7 +375,7 @@ class FieldSpec:
370
375
  """
371
376
 
372
377
  name: str
373
- annotation: type
378
+ annotation: TypeForm | TypeVar | ForwardRef
374
379
  translation: TypeTranslation
375
380
  default: DefaultOrMissing = MISSING
376
381
  default_factory: Callable[[], FieldValue] | None = None
@@ -382,7 +387,9 @@ class FieldSpec:
382
387
  nominal: bool = False
383
388
  usage_mode: Literal["readwrite", "computed", "materialised"] = "readwrite"
384
389
  axioms: tuple[str, ...] = ()
385
- extras: Mapping[str, Opaque] = _dc_field(default_factory=dict)
390
+ extras: Mapping[str, Opaque] = _dc_field(
391
+ default_factory=lambda: cast("dict[str, Opaque]", {})
392
+ )
386
393
 
387
394
  @property
388
395
  def is_required(self) -> bool:
@@ -2,7 +2,6 @@
2
2
  # narrower payload type; the runtime accepts the base shape too. The
3
3
  # ``Literal[...]`` discriminator extraction goes through annotation
4
4
  # walking that pyright can't follow. Tracked in panproto/didactic#1.
5
- # pyright: reportArgumentType=false, reportIncompatibleMethodOverride=false, reportUnnecessaryIsInstance=false
6
5
  """Tagged (discriminated) unions over [Model][didactic.api.Model] subclasses.
7
6
 
8
7
  Pydantic-shaped discriminated unions: declare a base class with a
@@ -49,13 +48,15 @@ didactic.models._meta.ModelMeta : the metaclass; unchanged.
49
48
  from __future__ import annotations
50
49
 
51
50
  import annotationlib
52
- from typing import TYPE_CHECKING, ClassVar, Literal, Self, get_args, get_origin
51
+ from typing import TYPE_CHECKING, ClassVar, Literal, Self, cast, get_args, get_origin
53
52
 
54
53
  from didactic.fields._validators import ValidationError, ValidationErrorEntry
55
54
  from didactic.models._model import Model
56
55
 
57
56
  if TYPE_CHECKING:
58
- from didactic.types._typing import FieldValue, JsonObject, Opaque
57
+ from collections.abc import Mapping
58
+
59
+ from didactic.types._typing import FieldValue, JsonValue, Opaque
59
60
 
60
61
 
61
62
  class TaggedUnion(Model):
@@ -173,7 +174,7 @@ class TaggedUnion(Model):
173
174
  root.__variants__[value] = cls
174
175
 
175
176
  @classmethod
176
- def model_validate(cls, payload: JsonObject) -> Self:
177
+ def model_validate(cls, payload: Mapping[str, FieldValue | JsonValue]) -> Self:
177
178
  """Dispatch on the discriminator and validate as the matched variant.
178
179
 
179
180
  Parameters
@@ -212,7 +213,10 @@ class TaggedUnion(Model):
212
213
  )
213
214
  raise ValidationError(entries=(entry,), model=cls)
214
215
 
215
- value = payload[disc]
216
+ # ``payload`` is widened to ``Mapping[str, FieldValue | JsonValue]``
217
+ # to match the base ``model_validate`` signature; the discriminator
218
+ # value is recorded in ``__variants__`` under a ``FieldValue`` key.
219
+ value = cast("FieldValue", payload[disc])
216
220
  variant = cls.__variants__.get(value)
217
221
  if variant is None:
218
222
  entry = ValidationErrorEntry(
@@ -225,22 +229,24 @@ class TaggedUnion(Model):
225
229
  )
226
230
  raise ValidationError(entries=(entry,), model=cls)
227
231
 
228
- return variant.model_validate(payload) # type: ignore[return-value]
232
+ # ``variant`` is a concrete subclass of ``cls``; the return type
233
+ # is bound to ``Self`` of the union root by design (the dispatch
234
+ # contract is "give me a value of *some* variant").
235
+ return cast("Self", variant.model_validate(payload))
229
236
 
230
237
 
231
238
  def _find_union_root(cls: type[TaggedUnion]) -> type[TaggedUnion] | None:
232
239
  """Walk MRO to find the nearest ancestor with a non-None ``__discriminator__``."""
233
240
  for klass in cls.__mro__[1:]:
234
241
  if (
235
- isinstance(klass, type)
236
- and issubclass(klass, TaggedUnion)
242
+ issubclass(klass, TaggedUnion)
237
243
  and klass.__dict__.get("__discriminator__") is not None
238
244
  ):
239
245
  return klass
240
246
  return None
241
247
 
242
248
 
243
- def _literal_values(annotation: type) -> tuple[FieldValue, ...]:
249
+ def _literal_values(annotation: Opaque) -> tuple[FieldValue, ...]:
244
250
  """Extract the values from a ``Literal[...]`` annotation.
245
251
 
246
252
  Returns an empty tuple when the annotation isn't a Literal.
@@ -22,9 +22,6 @@ didactic.models._meta : the metaclass that records validators on the class.
22
22
 
23
23
  # Stamps ``__didactic_validator__`` onto a function; pyright won't
24
24
  # let arbitrary attribute assignment on a ``FunctionType``.
25
- # Tracked in panproto/didactic#1.
26
- # pyright: reportFunctionMemberAccess=false
27
-
28
25
  from __future__ import annotations
29
26
 
30
27
  from dataclasses import dataclass, field
@@ -150,8 +147,14 @@ def validates(
150
147
  def decorator(
151
148
  fn: Callable[..., FieldValue],
152
149
  ) -> Callable[..., FieldValue]:
153
- # mark the function so the metaclass can find it
154
- fn.__didactic_validator__ = {"fields": field_names, "mode": mode}
150
+ # Mark the function so the metaclass can find it. ``setattr``
151
+ # writes through the function-object's ``__dict__`` without
152
+ # tripping pyright's narrowed FunctionType view.
153
+ setattr( # noqa: B010
154
+ fn,
155
+ "__didactic_validator__",
156
+ {"fields": field_names, "mode": mode},
157
+ )
155
158
  return fn
156
159
 
157
160
  return decorator
@@ -42,14 +42,9 @@ didactic.Lens : the schema-fixed lens type.
42
42
  didactic.Iso : the isomorphism subcase.
43
43
  """
44
44
 
45
- # ``ProtolensChain.instantiate(src, tgt)`` accepts the project's
46
- # ``Schema`` Protocol; pyright sees the panproto stub class instead.
47
- # Tracked in panproto/didactic#1.
48
- # pyright: reportArgumentType=false
49
-
50
45
  from __future__ import annotations
51
46
 
52
- from typing import TYPE_CHECKING
47
+ from typing import TYPE_CHECKING, cast
53
48
 
54
49
  if TYPE_CHECKING:
55
50
  import panproto
@@ -280,7 +275,12 @@ class DependentLens:
280
275
  panproto.LensError
281
276
  If the chain cannot be instantiated against ``schema``.
282
277
  """
283
- return self._inner.instantiate(schema, protocol)
278
+ # ``ProtolensChain.instantiate``'s shipped stub claims
279
+ # ``(src: Schema, tgt: Schema)``; the runtime is
280
+ # ``(schema, protocol)`` with the second arg being a
281
+ # ``panproto.Protocol``. Cast at the boundary so we type-match
282
+ # the stub while passing the runtime's expected value.
283
+ return self._inner.instantiate(schema, cast("panproto.Schema", protocol))
284
284
 
285
285
  # serialisation -------------------------------------------------
286
286
 
@@ -51,9 +51,6 @@ didactic.theory._theory : the bridge that will compile lenses to panproto.Lens.
51
51
 
52
52
  # ``lens`` doubles as a callable and a namespace (``dx.lens.identity``);
53
53
  # the namespace attributes are stamped on the function object.
54
- # Tracked in panproto/didactic#1.
55
- # pyright: reportAttributeAccessIssue=false
56
-
57
54
  from __future__ import annotations
58
55
 
59
56
  import functools
@@ -70,7 +67,6 @@ from didactic.types._typing import Opaque
70
67
  if TYPE_CHECKING:
71
68
  from collections.abc import Callable
72
69
 
73
-
74
70
  # ---------------------------------------------------------------------------
75
71
  # Mapping (one-way)
76
72
  # ---------------------------------------------------------------------------
@@ -377,11 +373,18 @@ class _LensNamespace:
377
373
  ``dx.lens.Lens``, ``dx.lens.Iso``, ``dx.lens.Mapping``).
378
374
  """
379
375
 
376
+ identity: Callable[..., Iso[Opaque, Opaque]]
377
+ Lens: type[Lens[Opaque, Opaque, Opaque]]
378
+ Iso: type[Iso[Opaque, Opaque]]
379
+ Mapping: type[Mapping[Opaque, Opaque]]
380
+
380
381
  def __init__(self) -> None:
381
- self.identity: Callable[..., Iso[Opaque, Opaque]] = identity
382
- self.Lens: type[Lens[Opaque, Opaque, Opaque]] = Lens
383
- self.Iso: type[Iso[Opaque, Opaque]] = Iso
384
- self.Mapping: type[Mapping[Opaque, Opaque]] = Mapping
382
+ # ``identity`` is parameterised on ``A``; the namespace exposes the
383
+ # erased ``Opaque, Opaque`` shape since callers re-bind on use.
384
+ self.identity = cast("Callable[..., Iso[Opaque, Opaque]]", identity)
385
+ self.Lens = Lens
386
+ self.Iso = Iso
387
+ self.Mapping = Mapping
385
388
 
386
389
  def __call__[A, B](
387
390
  self, source: type[A], target: type[B]
@@ -434,7 +437,6 @@ class _LensNamespace:
434
437
 
435
438
  lens = _LensNamespace()
436
439
 
437
-
438
440
  __all__ = [
439
441
  "Iso",
440
442
  "Lens",
@@ -102,8 +102,8 @@ def verify_iso[A](
102
102
  _check()
103
103
 
104
104
 
105
- def check_lens_laws[A, B](
106
- lens: Lens[A, B],
105
+ def check_lens_laws[A, B, C](
106
+ lens: Lens[A, B, C],
107
107
  strategy: SearchStrategy[A],
108
108
  *,
109
109
  max_examples: int = 100,
@@ -1,7 +1,6 @@
1
1
  # ``diff_and_classify`` returns a ``CompatReport`` whose ``.to_dict()``
2
2
  # we re-emit as ``JsonObject``; pyright's stub doesn't narrow the
3
3
  # panproto-side types tightly enough. Tracked in panproto/didactic#1.
4
- # pyright: reportReturnType=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportCallIssue=false
5
4
  """Schema diff and breaking-change detection.
6
5
 
7
6
  Two thin functions over panproto's ``diff_schemas`` and
@@ -22,9 +21,11 @@ panproto.diff_schemas : the runtime call.
22
21
 
23
22
  from __future__ import annotations
24
23
 
25
- from typing import TYPE_CHECKING
24
+ from typing import TYPE_CHECKING, Protocol, cast
26
25
 
27
26
  if TYPE_CHECKING:
27
+ import panproto
28
+
28
29
  from didactic.models._model import Model
29
30
  from didactic.types._typing import JsonObject
30
31
 
@@ -65,7 +66,7 @@ def diff(old: type[Model], new: type[Model]) -> JsonObject:
65
66
  old_schema = schema_from_model(old)
66
67
  new_schema = schema_from_model(new)
67
68
  schema_diff = panproto.diff_schemas(old_schema, new_schema)
68
- return schema_diff.to_dict()
69
+ return cast("JsonObject", schema_diff.to_dict())
69
70
 
70
71
 
71
72
  def classify_change(old: type[Model], new: type[Model]) -> JsonObject:
@@ -111,7 +112,24 @@ def classify_change(old: type[Model], new: type[Model]) -> JsonObject:
111
112
  schema_theory=build_theory(new),
112
113
  obj_kinds=["object"],
113
114
  )
114
- compat = panproto.diff_and_classify(old_schema, new_schema, protocol)
115
+
116
+ # ``panproto.diff_and_classify`` accepts a third positional ``protocol``
117
+ # argument at runtime; the upstream stub still lists only two. Cast
118
+ # to a Protocol that exposes the runtime arity and return shape.
119
+ class _CompatReportLike(Protocol):
120
+ def to_dict(self) -> JsonObject: ...
121
+
122
+ class _DiffAndClassifyLike(Protocol):
123
+ def __call__(
124
+ self,
125
+ old: panproto.Schema,
126
+ new: panproto.Schema,
127
+ protocol: panproto.Protocol,
128
+ /,
129
+ ) -> _CompatReportLike: ...
130
+
131
+ diff_and_classify = cast("_DiffAndClassifyLike", panproto.diff_and_classify)
132
+ compat = diff_and_classify(old_schema, new_schema, protocol)
115
133
  return compat.to_dict()
116
134
 
117
135