metaobjects 0.9.0__py3-none-any.whl

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 (181) hide show
  1. metaobjects/__init__.py +75 -0
  2. metaobjects/agent_context/__init__.py +55 -0
  3. metaobjects/agent_context/_content/README.md +14 -0
  4. metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
  5. metaobjects/agent_context/_content/servers/java.meta.json +5 -0
  6. metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
  7. metaobjects/agent_context/_content/servers/python.meta.json +5 -0
  8. metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
  9. metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
  10. metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
  11. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
  12. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
  13. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
  14. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
  15. metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
  16. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
  17. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
  18. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
  19. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
  20. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
  21. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
  22. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
  23. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
  24. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
  25. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
  26. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
  27. metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
  28. metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
  29. metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
  30. metaobjects/agent_context/assemble.py +133 -0
  31. metaobjects/agent_context/content_root.py +54 -0
  32. metaobjects/agent_context/scaffold.py +191 -0
  33. metaobjects/agent_context/types.py +44 -0
  34. metaobjects/attr_class_map.py +23 -0
  35. metaobjects/cli.py +696 -0
  36. metaobjects/codegen/__init__.py +0 -0
  37. metaobjects/codegen/config.py +11 -0
  38. metaobjects/codegen/constants.py +13 -0
  39. metaobjects/codegen/extract_delegate_emitter.py +384 -0
  40. metaobjects/codegen/extract_schema_emitter.py +139 -0
  41. metaobjects/codegen/format.py +31 -0
  42. metaobjects/codegen/fr010_field_mapping.py +220 -0
  43. metaobjects/codegen/generator.py +62 -0
  44. metaobjects/codegen/generator_registry.py +163 -0
  45. metaobjects/codegen/generators/__init__.py +0 -0
  46. metaobjects/codegen/generators/entity_model.py +263 -0
  47. metaobjects/codegen/generators/extractor_generator.py +317 -0
  48. metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
  49. metaobjects/codegen/generators/m2m_codegen.py +192 -0
  50. metaobjects/codegen/generators/output_parser_generator.py +272 -0
  51. metaobjects/codegen/generators/output_prompt_generator.py +192 -0
  52. metaobjects/codegen/generators/payload_vo_generator.py +672 -0
  53. metaobjects/codegen/generators/render_helper_generator.py +451 -0
  54. metaobjects/codegen/generators/router_generator.py +635 -0
  55. metaobjects/codegen/generators/template_generator.py +70 -0
  56. metaobjects/codegen/generators/tph_plan.py +120 -0
  57. metaobjects/codegen/generators/trace_helper_generator.py +336 -0
  58. metaobjects/codegen/instance_artifacts.py +15 -0
  59. metaobjects/codegen/output_format_spec_emitter.py +79 -0
  60. metaobjects/codegen/overwrite_policy.py +27 -0
  61. metaobjects/codegen/runner.py +110 -0
  62. metaobjects/codegen/runtime/__init__.py +6 -0
  63. metaobjects/codegen/runtime/filter_parser.py +193 -0
  64. metaobjects/codegen/type_map.py +84 -0
  65. metaobjects/core_types.py +809 -0
  66. metaobjects/datatype.py +19 -0
  67. metaobjects/documentation/__init__.py +28 -0
  68. metaobjects/documentation/doc_constants.py +20 -0
  69. metaobjects/documentation/doc_provider.py +20 -0
  70. metaobjects/documentation/doc_schema.py +24 -0
  71. metaobjects/errors.py +124 -0
  72. metaobjects/loader/__init__.py +0 -0
  73. metaobjects/loader/merge.py +287 -0
  74. metaobjects/loader/meta_data_loader.py +245 -0
  75. metaobjects/loader/sources/__init__.py +24 -0
  76. metaobjects/loader/sources/directory_source.py +50 -0
  77. metaobjects/loader/sources/file_source.py +41 -0
  78. metaobjects/loader/sources/meta_data_source.py +67 -0
  79. metaobjects/loader/sources/uri_source.py +56 -0
  80. metaobjects/loader/validate_discriminator.py +181 -0
  81. metaobjects/loader/validate_field_readonly.py +146 -0
  82. metaobjects/loader/validate_source_parameter_ref.py +159 -0
  83. metaobjects/loader/validate_source_physical_names.py +140 -0
  84. metaobjects/loader/validation_passes.py +1513 -0
  85. metaobjects/meta/__init__.py +1 -0
  86. metaobjects/meta/core/__init__.py +0 -0
  87. metaobjects/meta/core/attr/__init__.py +0 -0
  88. metaobjects/meta/core/attr/attr_constants.py +31 -0
  89. metaobjects/meta/core/attr/meta_attr.py +136 -0
  90. metaobjects/meta/core/field/__init__.py +0 -0
  91. metaobjects/meta/core/field/field_constants.py +105 -0
  92. metaobjects/meta/core/field/meta_field.py +76 -0
  93. metaobjects/meta/core/identity/__init__.py +0 -0
  94. metaobjects/meta/core/identity/identity_constants.py +19 -0
  95. metaobjects/meta/core/identity/meta_identity.py +8 -0
  96. metaobjects/meta/core/object/__init__.py +0 -0
  97. metaobjects/meta/core/object/meta_object.py +65 -0
  98. metaobjects/meta/core/object/meta_object_aware.py +43 -0
  99. metaobjects/meta/core/object/object_class_registry.py +56 -0
  100. metaobjects/meta/core/object/object_constants.py +13 -0
  101. metaobjects/meta/core/object/object_extract.py +400 -0
  102. metaobjects/meta/core/object/value_object.py +70 -0
  103. metaobjects/meta/core/relationship/__init__.py +0 -0
  104. metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
  105. metaobjects/meta/core/relationship/meta_relationship.py +54 -0
  106. metaobjects/meta/core/relationship/relationship_constants.py +51 -0
  107. metaobjects/meta/core/validator/__init__.py +0 -0
  108. metaobjects/meta/core/validator/validator_constants.py +18 -0
  109. metaobjects/meta/meta_data.py +206 -0
  110. metaobjects/meta/meta_root.py +8 -0
  111. metaobjects/meta/persistence/__init__.py +0 -0
  112. metaobjects/meta/persistence/db/__init__.py +1 -0
  113. metaobjects/meta/persistence/db/db_constants.py +41 -0
  114. metaobjects/meta/persistence/db/db_provider.py +60 -0
  115. metaobjects/meta/persistence/origin/__init__.py +0 -0
  116. metaobjects/meta/persistence/origin/meta_origin.py +8 -0
  117. metaobjects/meta/persistence/origin/origin_constants.py +20 -0
  118. metaobjects/meta/persistence/source/__init__.py +0 -0
  119. metaobjects/meta/persistence/source/meta_source.py +137 -0
  120. metaobjects/meta/persistence/source/source_constants.py +115 -0
  121. metaobjects/meta/presentation/__init__.py +0 -0
  122. metaobjects/meta/presentation/layout/__init__.py +0 -0
  123. metaobjects/meta/presentation/layout/layout_constants.py +13 -0
  124. metaobjects/meta/presentation/layout/meta_layout.py +8 -0
  125. metaobjects/meta/presentation/view/__init__.py +0 -0
  126. metaobjects/meta/presentation/view/meta_view.py +8 -0
  127. metaobjects/meta/presentation/view/view_constants.py +22 -0
  128. metaobjects/meta/template/__init__.py +0 -0
  129. metaobjects/meta/template/meta_template.py +46 -0
  130. metaobjects/meta/template/template_constants.py +112 -0
  131. metaobjects/meta/template/template_provider.py +43 -0
  132. metaobjects/parser.py +380 -0
  133. metaobjects/parser_yaml.py +82 -0
  134. metaobjects/provider.py +111 -0
  135. metaobjects/py.typed +0 -0
  136. metaobjects/registry.py +210 -0
  137. metaobjects/registry_manifest.py +223 -0
  138. metaobjects/render/__init__.py +74 -0
  139. metaobjects/render/email_document.py +14 -0
  140. metaobjects/render/escapers.py +109 -0
  141. metaobjects/render/extract/__init__.py +59 -0
  142. metaobjects/render/extract/coerce.py +279 -0
  143. metaobjects/render/extract/extract.py +211 -0
  144. metaobjects/render/extract/extract_map.py +61 -0
  145. metaobjects/render/extract/json_forgiving_reader.py +203 -0
  146. metaobjects/render/extract/locate.py +65 -0
  147. metaobjects/render/extract/normalize.py +96 -0
  148. metaobjects/render/extract/strip.py +20 -0
  149. metaobjects/render/extract/types.py +332 -0
  150. metaobjects/render/extract/xml_forgiving_reader.py +162 -0
  151. metaobjects/render/filesystem_provider.py +51 -0
  152. metaobjects/render/prompt/__init__.py +32 -0
  153. metaobjects/render/prompt/output_format_renderer.py +340 -0
  154. metaobjects/render/prompt/output_format_spec.py +28 -0
  155. metaobjects/render/prompt/prompt_field.py +29 -0
  156. metaobjects/render/prompt/prompt_overrides.py +29 -0
  157. metaobjects/render/prompt/prompt_style.py +38 -0
  158. metaobjects/render/renderer.py +358 -0
  159. metaobjects/render/verify.py +266 -0
  160. metaobjects/runtime/__init__.py +39 -0
  161. metaobjects/runtime/llm_recorder.py +210 -0
  162. metaobjects/runtime/n2m_resolver.py +155 -0
  163. metaobjects/runtime/object_manager.py +715 -0
  164. metaobjects/runtime/tph.py +50 -0
  165. metaobjects/serializer_json.py +172 -0
  166. metaobjects/shared/__init__.py +0 -0
  167. metaobjects/shared/base_types.py +16 -0
  168. metaobjects/shared/separators.py +4 -0
  169. metaobjects/shared/structural.py +9 -0
  170. metaobjects/source/__init__.py +79 -0
  171. metaobjects/source/error_source.py +266 -0
  172. metaobjects/source/json_path.py +106 -0
  173. metaobjects/source/semantic_diff.py +98 -0
  174. metaobjects/source/yaml_positions.py +174 -0
  175. metaobjects/super_resolve.py +128 -0
  176. metaobjects/yaml_desugar.py +481 -0
  177. metaobjects-0.9.0.dist-info/METADATA +97 -0
  178. metaobjects-0.9.0.dist-info/RECORD +181 -0
  179. metaobjects-0.9.0.dist-info/WHEEL +4 -0
  180. metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
  181. metaobjects-0.9.0.dist-info/licenses/LICENSE +189 -0
@@ -0,0 +1,245 @@
1
+ """MetaDataLoader: source-polymorphic loader.
2
+
3
+ The cross-language Tier-1 loader: takes a list of MetaDataSource impls,
4
+ runs parse -> merge -> super-resolve -> validate -> freeze, and returns a
5
+ LoadResult { root, errors, warnings }.
6
+
7
+ Source format (JSON vs YAML) is declared by the source, not sniffed by the
8
+ loader. This replaces both the per-file extension switch from the old
9
+ free function and the TS FileMetaDataLoader.parseSource override.
10
+
11
+ The three class-method factories (from_directory / from_uris / from_string)
12
+ cover the 99% case; module-level shortcuts in `metaobjects.__init__` wrap
13
+ them with Pythonic ergonomics.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ from ..core_types import core_providers
22
+ from ..errors import ErrorCode, MetaError, ParseError
23
+ from ..meta.meta_data import MetaData
24
+ from ..meta.meta_root import MetaRoot
25
+ from ..parser import ParseResult, parse_document
26
+ from ..parser_yaml import parse_yaml
27
+ from ..provider import Provider, compose_registry
28
+ from ..registry import TypeRegistry
29
+ from ..shared.base_types import SUBTYPE_ROOT, TYPE_METADATA
30
+ from ..source import CodeSource, ErrorSource, JsonSource, LoaderWarning
31
+ from ..super_resolve import resolve_supers
32
+ from .merge import merge_roots
33
+ from .sources import (
34
+ DirectorySource,
35
+ InMemoryStringSource,
36
+ MetaDataFormat,
37
+ MetaDataSource,
38
+ UriSource,
39
+ )
40
+ from .validation_passes import run_validations
41
+
42
+
43
+ @dataclass
44
+ class LoadResult:
45
+ """The Tier-1 load result. Same field shape across all four ports."""
46
+
47
+ root: MetaData
48
+ errors: list[MetaError] = field(default_factory=list)
49
+ warnings: list[str] = field(default_factory=list)
50
+ # FR5c — envelope-shaped warnings (e.g. ``WARN_DUPLICATE_DECLARATION``)
51
+ # produced during parse / merge. Distinct from the legacy
52
+ # ``warnings: list[str]`` channel: those flow through the parser /
53
+ # validator surface as plain strings and get wrapped at the loader
54
+ # boundary (or surfaced verbatim by the conformance runner for the
55
+ # expected-warnings.json check); envelope warnings already carry their
56
+ # own ``code`` + ``source``. Mirrors TS ``LoadResult.warnings`` (whose
57
+ # singular channel ships ``LoaderWarning`` envelopes after the FR5c
58
+ # WARN_LEGACY-wrapping pass).
59
+ envelope_warnings: list[LoaderWarning] = field(default_factory=list)
60
+
61
+ def get_envelope_warnings(self) -> list[LoaderWarning]:
62
+ """FR5c — accessor mirroring TS ``loader.envelopeWarnings`` /
63
+ Java ``loader.getEnvelopeWarnings()``.
64
+
65
+ Returns the in-order list of envelope-shaped warnings (typed with a
66
+ ``code`` + ``source`` envelope) produced during parse / merge. The
67
+ legacy string channel (``self.warnings``) is unchanged.
68
+ """
69
+ return list(self.envelope_warnings)
70
+
71
+
72
+ class MetaDataLoader:
73
+ """Source-polymorphic metadata loader.
74
+
75
+ Construct once with a provider list (defaults to ``core_providers`` —
76
+ core types + DB-domain + documentation + template/output domain);
77
+ call ``.load(sources)`` for arbitrary source combinations, or use the
78
+ ``from_*`` class-method factories for the common cases.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ providers: list[Provider] | None = None,
84
+ strict: bool = False,
85
+ ) -> None:
86
+ self._registry: TypeRegistry = compose_registry(
87
+ providers if providers is not None else list(core_providers)
88
+ )
89
+ # ADR-0023 — strict load closes the open-attr policy: an authored own
90
+ # @-attr matching no per-type schema and no commonAttr → ERR_UNKNOWN_ATTR
91
+ # (alongside Python's always-on unknown TYPE/SUBTYPE rejection). Defaults
92
+ # False so a downstream app keeps the legacy open-attr behavior; the
93
+ # library's own conformance corpora load strict.
94
+ self._strict: bool = strict
95
+
96
+ @property
97
+ def registry(self) -> TypeRegistry:
98
+ return self._registry
99
+
100
+ # --- core API ------------------------------------------------------
101
+
102
+ def load(self, sources: list[MetaDataSource]) -> LoadResult:
103
+ """Parse -> merge -> super-resolve -> validate -> freeze."""
104
+ # Canonical empty root (matches parse_document's seed).
105
+ result = LoadResult(root=MetaRoot(TYPE_METADATA, SUBTYPE_ROOT, ""))
106
+ roots: list[MetaData] = []
107
+
108
+ for src in sources:
109
+ parsed = self._parse_source(src, result.errors)
110
+ if parsed is None:
111
+ continue
112
+ result.errors.extend(parsed.errors)
113
+ result.warnings.extend(parsed.warnings)
114
+ # FR5c — collect envelope-shaped warnings from each parse pass.
115
+ # Today these only arrive from merge sites (see merge_roots);
116
+ # the channel is exposed here for future parser-emitted ones.
117
+ result.envelope_warnings.extend(parsed.envelope_warnings)
118
+ if not parsed.errors:
119
+ roots.append(parsed.root)
120
+
121
+ if roots:
122
+ # FR5c — merge_roots is the site that emits ERR_MERGE_CONFLICT
123
+ # into errors and WARN_DUPLICATE_DECLARATION into both channels
124
+ # (legacy string + envelope) so the conformance runner's
125
+ # expected-warnings.json check sees the string forms while
126
+ # downstream tooling can consume the envelope shape.
127
+ result.root = merge_roots(
128
+ roots,
129
+ result.errors,
130
+ result.warnings,
131
+ result.envelope_warnings,
132
+ )
133
+ resolve_supers(result.root, result.errors)
134
+
135
+ run_validations(
136
+ result.root,
137
+ self._registry,
138
+ result.errors,
139
+ result.warnings,
140
+ envelope_warnings=result.envelope_warnings,
141
+ strict=self._strict,
142
+ )
143
+ result.root.freeze()
144
+ return result
145
+
146
+ # --- static factories (the 99% case, cross-language consistent) ----
147
+
148
+ @classmethod
149
+ def from_directory(
150
+ cls,
151
+ directory: Path | str,
152
+ providers: list[Provider] | None = None,
153
+ exclude: list[str] | None = None,
154
+ recurse: bool = True,
155
+ strict: bool = False,
156
+ ) -> LoadResult:
157
+ """Load every JSON/YAML file under ``directory`` (recursive by default).
158
+
159
+ ``strict`` (ADR-0023) — when True, an undeclared own ``@-attr`` →
160
+ ``ERR_UNKNOWN_ATTR``. Defaults False (downstream-friendly open policy).
161
+ """
162
+ loader = cls(providers=providers, strict=strict)
163
+ sources = list(
164
+ DirectorySource(directory, exclude=exclude, recurse=recurse).expand()
165
+ )
166
+ return loader.load(sources)
167
+
168
+ @classmethod
169
+ def from_uris(
170
+ cls,
171
+ uris: list[str],
172
+ providers: list[Provider] | None = None,
173
+ strict: bool = False,
174
+ ) -> LoadResult:
175
+ """Load metadata from a list of URIs (file:// or http(s)://).
176
+
177
+ ``strict`` (ADR-0023) — see :meth:`from_directory`.
178
+ """
179
+ loader = cls(providers=providers, strict=strict)
180
+ return loader.load([UriSource(u) for u in uris])
181
+
182
+ @classmethod
183
+ def from_string(
184
+ cls,
185
+ content: str,
186
+ format: MetaDataFormat = MetaDataFormat.JSON,
187
+ providers: list[Provider] | None = None,
188
+ strict: bool = False,
189
+ ) -> LoadResult:
190
+ """Load metadata from an in-memory string (defaults to JSON).
191
+
192
+ ``strict`` (ADR-0023) — see :meth:`from_directory`.
193
+ """
194
+ loader = cls(providers=providers, strict=strict)
195
+ return loader.load([InMemoryStringSource(content, format=format)])
196
+
197
+ # --- internals -----------------------------------------------------
198
+
199
+ def _parse_source(
200
+ self, src: MetaDataSource, errors: list[MetaError]
201
+ ) -> ParseResult | None:
202
+ """Read a source's content and dispatch to the JSON or YAML parser.
203
+
204
+ Returns ``None`` if the source could not be read or its syntax was
205
+ malformed; the caller appends an error and skips this source from
206
+ the merge set.
207
+ """
208
+ # FR5a / ADR-0009 — top-level envelope for the source. Until the parser
209
+ # walk descends, the offending location is the root (`$`). Matches C#
210
+ # Parser.cs:140 — only fall back to CodeSource when there is no source
211
+ # id at all; "<inline>" is a valid source identifier and must yield a
212
+ # JsonSource so envelope formats remain consistent across error sites.
213
+ envelope: ErrorSource = (
214
+ JsonSource(files=(src.id,), json_path="$")
215
+ if src.id
216
+ else CodeSource.DEFAULT
217
+ )
218
+
219
+ try:
220
+ text = src.read()
221
+ except OSError as exc:
222
+ errors.append(MetaError(
223
+ str(exc), ErrorCode.ERR_UNKNOWN, src.id, envelope=envelope,
224
+ ))
225
+ return None
226
+
227
+ match src.format:
228
+ case MetaDataFormat.YAML:
229
+ try:
230
+ return parse_yaml(text, self._registry, source=src.id)
231
+ except ParseError as exc:
232
+ errors.append(MetaError(
233
+ str(exc), exc.code, src.id, envelope=envelope,
234
+ ))
235
+ return None
236
+ case MetaDataFormat.JSON:
237
+ try:
238
+ doc = json.loads(text)
239
+ except json.JSONDecodeError as exc:
240
+ errors.append(MetaError(
241
+ str(exc), ErrorCode.ERR_MALFORMED_JSON, src.id,
242
+ envelope=envelope,
243
+ ))
244
+ return None
245
+ return parse_document(doc, self._registry, source=src.id)
@@ -0,0 +1,24 @@
1
+ """Polymorphic MetaDataSource implementations.
2
+
3
+ The cross-language Tier-1 contract: every source declares an identity (for
4
+ error locations), a format (json | yaml), and a read() returning the decoded
5
+ text content. File / directory / URI / in-memory sources are all just
6
+ implementations of that contract — the loader is source-agnostic.
7
+
8
+ See `docs/superpowers/specs/2026-05-25-cross-language-loader-architecture-unification.md`.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from .directory_source import DirectorySource
13
+ from .file_source import FileSource
14
+ from .meta_data_source import InMemoryStringSource, MetaDataFormat, MetaDataSource
15
+ from .uri_source import UriSource
16
+
17
+ __all__ = [
18
+ "MetaDataFormat",
19
+ "MetaDataSource",
20
+ "InMemoryStringSource",
21
+ "FileSource",
22
+ "DirectorySource",
23
+ "UriSource",
24
+ ]
@@ -0,0 +1,50 @@
1
+ """Directory expander -> sorted list of FileSource.
2
+
3
+ Walks the directory (recursively by default), filters to supported authoring
4
+ extensions (``.json`` / ``.yaml`` / ``.yml``), honors a name-based exclude
5
+ list, and yields FileSource instances in deterministic ordinal-filename
6
+ order. Deterministic order is required because overlay merging is
7
+ order-sensitive (last-writer-wins on attr conflicts).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable, Iterator
12
+ from pathlib import Path
13
+
14
+ from .file_source import FileSource
15
+
16
+ _SUPPORTED_SUFFIXES = (".json", ".yaml", ".yml")
17
+
18
+
19
+ class DirectorySource:
20
+ """Expands a directory into a sorted, filtered list of FileSource objects."""
21
+
22
+ def __init__(
23
+ self,
24
+ directory: Path | str,
25
+ exclude: Iterable[str] | None = None,
26
+ recurse: bool = True,
27
+ ) -> None:
28
+ self._directory = Path(directory)
29
+ self._exclude = set(exclude or ())
30
+ self._recurse = recurse
31
+
32
+ @property
33
+ def directory(self) -> Path:
34
+ return self._directory
35
+
36
+ def expand(self) -> Iterator[FileSource]:
37
+ candidates = (
38
+ self._directory.rglob("*") if self._recurse else self._directory.iterdir()
39
+ )
40
+ files = sorted(
41
+ (
42
+ p
43
+ for p in candidates
44
+ if p.is_file()
45
+ and p.suffix.lower() in _SUPPORTED_SUFFIXES
46
+ and p.name not in self._exclude
47
+ ),
48
+ key=lambda p: p.name,
49
+ )
50
+ yield from (FileSource(p) for p in files)
@@ -0,0 +1,41 @@
1
+ """Single-file MetaDataSource.
2
+
3
+ Format defaults to extension-derived (``.yaml`` / ``.yml`` -> YAML; otherwise
4
+ JSON). Reads with ``utf-8-sig`` so a leading UTF-8 BOM is silently stripped —
5
+ matches the prior `load_directory` behavior.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from .meta_data_source import MetaDataFormat, MetaDataSource
12
+
13
+
14
+ def _infer_format(path: Path) -> MetaDataFormat:
15
+ suffix = path.suffix.lower()
16
+ if suffix in (".yaml", ".yml"):
17
+ return MetaDataFormat.YAML
18
+ return MetaDataFormat.JSON
19
+
20
+
21
+ class FileSource(MetaDataSource):
22
+ """A single on-disk file, decoded eagerly via ``utf-8-sig``."""
23
+
24
+ def __init__(self, path: Path | str, format: MetaDataFormat | None = None) -> None:
25
+ self._path = Path(path)
26
+ self._format = format if format is not None else _infer_format(self._path)
27
+
28
+ @property
29
+ def path(self) -> Path:
30
+ return self._path
31
+
32
+ @property
33
+ def id(self) -> str:
34
+ return self._path.name
35
+
36
+ @property
37
+ def format(self) -> MetaDataFormat:
38
+ return self._format
39
+
40
+ def read(self) -> str:
41
+ return self._path.read_text(encoding="utf-8-sig")
@@ -0,0 +1,67 @@
1
+ """MetaDataSource abstract base + InMemoryStringSource impl.
2
+
3
+ Tier-1 contract: identity (for MetaError.location), declared format, and a
4
+ read() that returns decoded UTF-8 content.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC, abstractmethod
9
+ from enum import Enum
10
+
11
+
12
+ class MetaDataFormat(str, Enum):
13
+ """Authoring format vocabulary. Cross-language Tier 1: lowercase strings."""
14
+
15
+ JSON = "json"
16
+ YAML = "yaml"
17
+
18
+
19
+ class MetaDataSource(ABC):
20
+ """A source of metadata content.
21
+
22
+ Implementations declare identity + format up front; bytes may be read
23
+ eagerly or lazily (the loader treats `read()` as decoded text).
24
+ """
25
+
26
+ @property
27
+ @abstractmethod
28
+ def id(self) -> str:
29
+ """Stable identity used in MetaError.location (e.g. file name, URI, ``<inline>``)."""
30
+
31
+ @property
32
+ @abstractmethod
33
+ def format(self) -> MetaDataFormat:
34
+ """Declared authoring format. Source-declared, never sniffed by the loader."""
35
+
36
+ @abstractmethod
37
+ def read(self) -> str:
38
+ """Return the decoded text content."""
39
+
40
+
41
+ class InMemoryStringSource(MetaDataSource):
42
+ """In-memory string source; no I/O.
43
+
44
+ Default identity is ``<inline>``; callers may pass a more descriptive id
45
+ (e.g. ``<test:foo>``) — it surfaces in MetaError.location.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ content: str,
51
+ id: str = "<inline>",
52
+ format: MetaDataFormat = MetaDataFormat.JSON,
53
+ ) -> None:
54
+ self._content = content
55
+ self._id = id
56
+ self._format = format
57
+
58
+ @property
59
+ def id(self) -> str:
60
+ return self._id
61
+
62
+ @property
63
+ def format(self) -> MetaDataFormat:
64
+ return self._format
65
+
66
+ def read(self) -> str:
67
+ return self._content
@@ -0,0 +1,56 @@
1
+ """URI-backed MetaDataSource.
2
+
3
+ Supports ``file://``, ``http://``, and ``https://`` schemes. Format defaults to
4
+ extension-derived from the URI path; callers may override.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+ from urllib.request import urlopen
11
+
12
+ from .meta_data_source import MetaDataFormat, MetaDataSource
13
+
14
+
15
+ def _infer_format(uri: str) -> MetaDataFormat:
16
+ path = urlparse(uri).path
17
+ suffix = Path(path).suffix.lower()
18
+ if suffix in (".yaml", ".yml"):
19
+ return MetaDataFormat.YAML
20
+ return MetaDataFormat.JSON
21
+
22
+
23
+ class UriSource(MetaDataSource):
24
+ """A URI-backed source. Lazily fetched on ``read()``."""
25
+
26
+ def __init__(
27
+ self,
28
+ uri: str,
29
+ format: MetaDataFormat | None = None,
30
+ timeout: float = 30.0,
31
+ ) -> None:
32
+ self._uri = uri
33
+ self._format = format if format is not None else _infer_format(uri)
34
+ self._timeout = timeout
35
+
36
+ @property
37
+ def id(self) -> str:
38
+ return self._uri
39
+
40
+ @property
41
+ def format(self) -> MetaDataFormat:
42
+ return self._format
43
+
44
+ def read(self) -> str:
45
+ parsed = urlparse(self._uri)
46
+ if parsed.scheme == "file":
47
+ # urlparse splits the leading slashes off the path on file:// URIs.
48
+ return Path(parsed.path).read_text(encoding="utf-8-sig")
49
+ if parsed.scheme in ("http", "https"):
50
+ # Schemes are explicitly allowlisted (file/http/https) above; arbitrary
51
+ # URI handlers (ftp, etc.) reject with ValueError before urlopen is called.
52
+ with urlopen(self._uri, timeout=self._timeout) as resp: # noqa: S310
53
+ return resp.read().decode("utf-8")
54
+ raise ValueError(
55
+ f"UriSource: unsupported scheme '{parsed.scheme}' on {self._uri}"
56
+ )
@@ -0,0 +1,181 @@
1
+ """FR-014 — TPH discriminator cross-attribute rules.
2
+
3
+ Codes (all errors):
4
+ * ``ERR_DISCRIMINATOR_FIELD_NOT_FOUND`` — ``@discriminator`` names a field
5
+ that does not exist on the entity (own or via extends chain).
6
+ * ``ERR_DISCRIMINATOR_VALUE_DUPLICATE`` — two subtypes of the same
7
+ ``@discriminator``-bearing root claim the same ``@discriminatorValue``.
8
+ * ``ERR_DISCRIMINATOR_VALUE_MISSING`` — a concrete (non-abstract) entity
9
+ extends a chain whose root carries ``@discriminator`` but lacks
10
+ ``@discriminatorValue``.
11
+ * ``ERR_DISCRIMINATOR_VALUE_TYPE_MISMATCH`` — ``@discriminatorValue`` cannot be
12
+ coerced to the discriminator field's subtype (enum: not in ``@values``;
13
+ integer-family: not numeric; string: always OK).
14
+
15
+ Mirrors the TS reference
16
+ ``packages/metadata/src/core/object/validate-discriminator.ts``.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ from ..errors import ErrorCode, MetaError
23
+ from ..meta.meta_data import MetaData
24
+ from ..meta.core.field.field_constants import (
25
+ FIELD_ATTR_VALUES,
26
+ FIELD_SUBTYPE_ENUM,
27
+ FIELD_SUBTYPE_INT,
28
+ FIELD_SUBTYPE_LONG,
29
+ FIELD_SUBTYPE_STRING,
30
+ )
31
+ from ..meta.core.object.object_constants import (
32
+ OBJECT_ATTR_DISCRIMINATOR,
33
+ OBJECT_ATTR_DISCRIMINATOR_VALUE,
34
+ OBJECT_SUBTYPE_ENTITY,
35
+ )
36
+ from ..shared.base_types import TYPE_FIELD, TYPE_OBJECT
37
+
38
+ _NUMERIC_DISCRIMINATOR_SUBTYPES = frozenset(
39
+ {FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG}
40
+ )
41
+ _INT_RE = re.compile(r"^-?\d+$")
42
+
43
+
44
+ def _find_field_on_entity(entity: MetaData, name: str) -> MetaData | None:
45
+ """A field with ``name`` on ``entity`` — own first, then via extends chain."""
46
+ for child in entity.own_children():
47
+ if child.type == TYPE_FIELD and child.name == name:
48
+ return child
49
+ cursor = entity.super_data
50
+ while cursor is not None:
51
+ for child in cursor.own_children():
52
+ if child.type == TYPE_FIELD and child.name == name:
53
+ return child
54
+ cursor = cursor.super_data
55
+ return None
56
+
57
+
58
+ def _find_discriminator_root(entity: MetaData) -> tuple[MetaData | None, str | None]:
59
+ """First ancestor (or self) carrying ``@discriminator``: (root, fieldName)."""
60
+ cursor: MetaData | None = entity
61
+ while cursor is not None:
62
+ v = cursor.attr(OBJECT_ATTR_DISCRIMINATOR)
63
+ if isinstance(v, str) and v != "":
64
+ return cursor, v
65
+ cursor = cursor.super_data
66
+ return None, None
67
+
68
+
69
+ def validate_discriminator(root: MetaData, errors: list[MetaError]) -> None:
70
+ entities = [
71
+ c
72
+ for c in root.own_children()
73
+ if c.type == TYPE_OBJECT and c.sub_type == OBJECT_SUBTYPE_ENTITY
74
+ ]
75
+
76
+ # Pass 1: @discriminator name resolution (own + inherited fields).
77
+ for obj in entities:
78
+ disc = obj.attr(OBJECT_ATTR_DISCRIMINATOR)
79
+ if not isinstance(disc, str) or disc == "":
80
+ continue
81
+ if _find_field_on_entity(obj, disc) is None:
82
+ errors.append(
83
+ MetaError(
84
+ f'object.entity "{obj.name}" @discriminator: "{disc}" does not '
85
+ "name a field on this entity (checked own children and the "
86
+ "extends chain)",
87
+ ErrorCode.ERR_DISCRIMINATOR_FIELD_NOT_FOUND,
88
+ envelope=obj.source,
89
+ )
90
+ )
91
+
92
+ # Pass 2: @discriminatorValue type-check + collect bindings per root.
93
+ bindings_by_root: list[tuple[MetaData, list[tuple[MetaData, str]]]] = []
94
+ _root_index: dict[int, list[tuple[MetaData, str]]] = {}
95
+
96
+ for obj in entities:
97
+ value = obj.attr(OBJECT_ATTR_DISCRIMINATOR_VALUE)
98
+ if not isinstance(value, str) or value == "":
99
+ continue
100
+
101
+ disc_root, field_name = _find_discriminator_root(obj)
102
+ if disc_root is None or field_name is None:
103
+ continue
104
+ field = _find_field_on_entity(disc_root, field_name)
105
+ if field is None:
106
+ continue # root's own ERR_DISCRIMINATOR_FIELD_NOT_FOUND already fires
107
+
108
+ if field.sub_type == FIELD_SUBTYPE_ENUM:
109
+ enum_values = field.attr(FIELD_ATTR_VALUES)
110
+ members = [str(v) for v in enum_values] if isinstance(enum_values, (list, tuple)) else []
111
+ if value not in members:
112
+ errors.append(
113
+ MetaError(
114
+ f'object.entity "{obj.name}" @discriminatorValue: "{value}" '
115
+ f'is not a member of the discriminator enum field '
116
+ f'"{field_name}" @values [{", ".join(members)}]',
117
+ ErrorCode.ERR_DISCRIMINATOR_VALUE_TYPE_MISMATCH,
118
+ envelope=obj.source,
119
+ )
120
+ )
121
+ elif field.sub_type in _NUMERIC_DISCRIMINATOR_SUBTYPES:
122
+ if _INT_RE.match(value) is None:
123
+ errors.append(
124
+ MetaError(
125
+ f'object.entity "{obj.name}" @discriminatorValue: "{value}" '
126
+ f'does not coerce to numeric discriminator field '
127
+ f'"{field_name}" (field.{field.sub_type})',
128
+ ErrorCode.ERR_DISCRIMINATOR_VALUE_TYPE_MISMATCH,
129
+ envelope=obj.source,
130
+ )
131
+ )
132
+ elif field.sub_type != FIELD_SUBTYPE_STRING:
133
+ # Non-{enum, integer-family, string} discriminators accepted silently.
134
+ pass
135
+
136
+ existing = _root_index.get(id(disc_root))
137
+ if existing is None:
138
+ existing = []
139
+ _root_index[id(disc_root)] = existing
140
+ bindings_by_root.append((disc_root, existing))
141
+ existing.append((obj, value))
142
+
143
+ # Pass 3: ERR_DISCRIMINATOR_VALUE_DUPLICATE within each root's subtypes.
144
+ for _disc_root, bindings in bindings_by_root:
145
+ seen: dict[str, MetaData] = {}
146
+ for subtype, value in bindings:
147
+ prev = seen.get(value)
148
+ if prev is not None:
149
+ errors.append(
150
+ MetaError(
151
+ f'object.entity "{subtype.name}" @discriminatorValue: '
152
+ f'"{value}" duplicates the value already claimed by '
153
+ f'"{prev.name}"',
154
+ ErrorCode.ERR_DISCRIMINATOR_VALUE_DUPLICATE,
155
+ envelope=subtype.source,
156
+ )
157
+ )
158
+ else:
159
+ seen[value] = subtype
160
+
161
+ # Pass 4: ERR_DISCRIMINATOR_VALUE_MISSING — every concrete entity that extends
162
+ # a @discriminator-bearing root must declare a value.
163
+ for obj in entities:
164
+ if obj.is_abstract is True:
165
+ continue
166
+ if isinstance(obj.attr(OBJECT_ATTR_DISCRIMINATOR_VALUE), str):
167
+ continue
168
+ if isinstance(obj.attr(OBJECT_ATTR_DISCRIMINATOR), str):
169
+ continue # a root, not a subtype
170
+ disc_root, _ = _find_discriminator_root(obj)
171
+ if disc_root is None or disc_root is obj:
172
+ continue
173
+ errors.append(
174
+ MetaError(
175
+ f'object.entity "{obj.name}" extends the @discriminator-bearing '
176
+ f'root "{disc_root.name}" but is missing @discriminatorValue '
177
+ "(required on every concrete subtype)",
178
+ ErrorCode.ERR_DISCRIMINATOR_VALUE_MISSING,
179
+ envelope=obj.source,
180
+ )
181
+ )