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,400 @@
1
+ """Phase B (metadata-driven extract) runtime entry point — the Python keystone
2
+ that turns dirty LLM text into a populated, typed object graph.
3
+
4
+ This is the runtime bridge between the two halves of the standard:
5
+
6
+ * the extract **engine** (:mod:`metaobjects.render.extract`: ``ExtractSchema`` /
7
+ ``FieldSpec`` / ``extract``) — a low-level, descriptor-driven module that parses
8
+ dirty JSON/XML into a forgiving ``dict``/``list`` tree + a ``ExtractionReport``. It
9
+ knows nothing of the runtime object model.
10
+ * the Phase A runtime **object model** (:mod:`metaobjects.meta.core.object`:
11
+ ``MetaObject.new_instance()`` + the ``MetaField`` get/set SPI + ``@objectRef``) —
12
+ instantiates the right backing type (``ValueObject`` or a registered/bound class)
13
+ with the correct back-reference.
14
+
15
+ **Siting.** ``render.extract`` is a LOWER layer and stays metadata-free by design
16
+ (coupling it to the metadata model would invert the layering). The metadata-driven
17
+ bridge therefore lives HERE, in :mod:`metaobjects.meta.core.object` — alongside the
18
+ Phase A object model, the natural home for a runtime API that needs BOTH ``MetaObject``
19
+ and the extract engine. This mirrors the JVM layering, where the extract engine sits
20
+ in the metadata-free ``render`` module and the runtime extract bridge lives in the
21
+ ``om`` runtime module (which depends on both); and the TS layering, where the bridge
22
+ lives in ``runtime-ts`` rather than the published ``render`` engine package. The edges
23
+ are one-way: this module → ``render.extract`` and this module → ``meta``; the engine
24
+ depends on neither, so there is no cycle.
25
+
26
+ **Reflection-free.** Assembly uses ``MetaObject.new_instance()`` (the
27
+ ``ObjectClassRegistry`` resolves the bound type, else a ``ValueObject``) and the
28
+ ``MetaField`` set-by-name SPI — no ``importlib`` / dynamic class resolution
29
+ (ADR-0001).
30
+
31
+ **Never throws.** Lost/malformed fields are classified in the report, never raised.
32
+ Opt into strictness with :func:`or_throw`, which raises :class:`ExtractError` iff a
33
+ required field was lost.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ from metaobjects.meta.core.field import field_constants as fc
38
+ from metaobjects.meta.core.field.meta_field import MetaField
39
+ from metaobjects.meta.core.validator import validator_constants as vc
40
+ from metaobjects.meta.template.template_constants import TEMPLATE_ATTR_XML_TEXT
41
+ from metaobjects.meta.meta_data import MetaData
42
+ from metaobjects.shared.base_types import TYPE_VALIDATOR
43
+ from metaobjects.render.extract import (
44
+ FieldKind,
45
+ FieldSpec,
46
+ Format,
47
+ ExtractOptions,
48
+ ExtractSchema,
49
+ ExtractionResult,
50
+ extract,
51
+ )
52
+ from metaobjects.shared.structural import KEY_IS_ARRAY
53
+
54
+ from .meta_object import MetaObject
55
+
56
+ #: Maximum nested-object recursion depth. Mirrors the render
57
+ #: ``OutputFormatRenderer.MAX_NEST_DEPTH`` (and FR-012) — must stay identical
58
+ #: cross-port (Java ``MetaObjectExtractor.MAX_NEST_DEPTH = 8``). At or beyond this depth
59
+ #: a nested OBJECT field becomes an opaque STRING leaf instead of recursing.
60
+ MAX_NEST_DEPTH = 8
61
+
62
+
63
+ # =============================================================================
64
+ # ExtractError + or_throw — the opt-in strictness gate
65
+ # =============================================================================
66
+
67
+
68
+ class ExtractError(Exception):
69
+ """Raised by :func:`or_throw` when a extraction lost one or more ``@required``
70
+ fields. ``lost_required`` lists the field paths that were absent/uncoercible."""
71
+
72
+ def __init__(self, lost_required: list[str]) -> None:
73
+ self.lost_required = list(lost_required)
74
+ joined = ", ".join(self.lost_required)
75
+ super().__init__(f"extract lost required field(s): {joined}")
76
+
77
+
78
+ def or_throw(result: ExtractionResult[object]) -> object:
79
+ """Opt into strictness: return ``result.data`` iff no ``@required`` field was
80
+ lost, else raise :class:`ExtractError`. Mirrors the cross-port ``ExtractionResult.
81
+ orThrow()`` / TS free ``orThrow``. Extract itself NEVER raises — this is the
82
+ explicit gate a caller reaches for when a lost-required field should fail."""
83
+ if result.report.has_lost_required():
84
+ raise ExtractError(result.report.lost_required())
85
+ return result.data
86
+
87
+
88
+ # =============================================================================
89
+ # Public API
90
+ # =============================================================================
91
+
92
+
93
+ def extract_object(
94
+ mo: MetaObject,
95
+ text: str | None,
96
+ format: Format = Format.JSON,
97
+ opts: ExtractOptions | None = None,
98
+ ) -> ExtractionResult[object]:
99
+ """Extract ``text`` into a typed object graph described by ``mo``.
100
+
101
+ Pipeline: build a ``ExtractSchema`` from ``mo`` (driven entirely by metadata),
102
+ run the engine to get a forgiving ``dict``/``list`` tree + report, then assemble
103
+ that tree into a populated object graph.
104
+
105
+ :param mo: the object describing the expected shape (the single source of truth)
106
+ :param text: the dirty model output
107
+ :param format: JSON (default) or XML — drives the engine's locate/parse
108
+ :param opts: bounded runtime overrides (aliases/normalizers/on_field/tolerance)
109
+ :returns: the assembled object (a ``ValueObject`` unless a type is bound for the
110
+ FQN) + report. NEVER ``None``; NEVER raises.
111
+ """
112
+ schema = extract_schema_for(mo, format)
113
+ outcome = extract(text, schema, opts)
114
+ obj = assemble(mo, outcome.data)
115
+ return ExtractionResult(data=obj, report=outcome.report)
116
+
117
+
118
+ # =============================================================================
119
+ # extract_schema_for — metadata -> ExtractSchema
120
+ # =============================================================================
121
+
122
+
123
+ def extract_schema_for(mo: MetaObject, format: Format = Format.JSON) -> ExtractSchema:
124
+ """Build a ``ExtractSchema`` for ``mo`` by walking its effective fields and
125
+ mapping each ``MetaField`` to a ``FieldSpec``. Recurses into nested OBJECT fields
126
+ via ``@objectRef``, guarded against cycles/over-depth by an identity visited-set
127
+ keyed on ``id(MetaObject)`` + a depth counter bounded by :data:`MAX_NEST_DEPTH`."""
128
+ return _extract_schema_for(mo, format, set(), 0)
129
+
130
+
131
+ def _extract_schema_for(
132
+ mo: MetaObject, format: Format, visited: set[int], depth: int
133
+ ) -> ExtractSchema:
134
+ visited.add(id(mo))
135
+ fields = [_field_spec_for(f, mo, format, visited, depth) for f in mo.fields()]
136
+ visited.discard(id(mo))
137
+ # root_name is the simple (short) name; the engine's XML locate uses it as the root tag.
138
+ return ExtractSchema(format, mo.name, fields)
139
+
140
+
141
+ def _field_spec_for(
142
+ field: MetaField,
143
+ owner: MetaObject,
144
+ format: Format,
145
+ visited: set[int],
146
+ depth: int,
147
+ ) -> FieldSpec:
148
+ name = field.name
149
+ required = _is_required(field)
150
+ array = _is_array(field)
151
+
152
+ # --- Enum (scalar or array) ----------------------------------------------
153
+ if field.sub_type == fc.FIELD_SUBTYPE_ENUM:
154
+ values = _enum_values(field)
155
+ aliases = _enum_aliases(field)
156
+ cd = _own_attr_string(field, fc.FIELD_ATTR_COERCE_DEFAULT)
157
+ dv = _own_attr_string(field, fc.FIELD_ATTR_DEFAULT)
158
+ normalize = _resolve_normalize(field, owner)
159
+ if array:
160
+ return FieldSpec.enum_array(name, required, values, aliases, cd, normalize, dv)
161
+ return FieldSpec.enum_field(name, required, values, aliases, cd, normalize, dv)
162
+
163
+ # --- Nested object (single or array) --------------------------------------
164
+ if field.sub_type == fc.FIELD_SUBTYPE_OBJECT or field.object_ref is not None:
165
+ ref = _resolve_object_ref(field)
166
+ if ref is None or id(ref) in visited or depth + 1 >= MAX_NEST_DEPTH:
167
+ # Opaque leaf — never recurse into an unresolved ref / a cycle / past the
168
+ # depth bound.
169
+ return FieldSpec.scalar(name, FieldKind.STRING, required)
170
+ nested = _extract_schema_for(ref, format, visited, depth + 1)
171
+ return FieldSpec.object_(name, required, array, nested)
172
+
173
+ # --- Scalar (carry generalized @default) ----------------------------------
174
+ kind = _scalar_kind(field.sub_type)
175
+ if array:
176
+ # Scalar array: the engine coerces each element; no per-element default fill.
177
+ return FieldSpec.scalar_array(name, kind, required)
178
+ # @xmlText: a non-array scalar marked to receive its element's XML text content
179
+ # (JAXB @XmlValue / Jackson @JacksonXmlText / .NET [XmlText]) instead of a same-named
180
+ # child. Mirrors the TS fieldSpecFor textContentField branch. No effect for JSON.
181
+ if _text_content(field):
182
+ return FieldSpec.text_content_field(name, kind, required)
183
+ # Numeric range: source the bound from the field's numeric validator (@min/@max) — the single
184
+ # source of truth — so the engine clamps (lenient) / rejects (strict) out-of-range values.
185
+ if kind in (FieldKind.INT, FieldKind.LONG, FieldKind.DOUBLE):
186
+ num_min = _numeric_bound(field, vc.VALIDATOR_ATTR_MIN)
187
+ num_max = _numeric_bound(field, vc.VALIDATOR_ATTR_MAX)
188
+ if num_min is not None or num_max is not None:
189
+ return FieldSpec.range_(name, kind, required, num_min, num_max)
190
+ dv = _own_attr_string(field, fc.FIELD_ATTR_DEFAULT)
191
+ return FieldSpec.scalar(name, kind, required, dv)
192
+
193
+
194
+ def _numeric_bound(field: MetaField, attr_name: str) -> float | None:
195
+ """Read a numeric bound (@min/@max) from the field's ``validator.numeric`` child — the
196
+ canonical range source — or ``None`` if there is no such validator/bound."""
197
+ for c in field.children():
198
+ if c.type == TYPE_VALIDATOR and c.sub_type == vc.VALIDATOR_SUBTYPE_NUMERIC:
199
+ val = c.attr(attr_name)
200
+ if isinstance(val, (int, float)) and not isinstance(val, bool):
201
+ return float(val)
202
+ return None
203
+
204
+
205
+ # =============================================================================
206
+ # assemble — extracted dict/list tree -> object graph
207
+ # =============================================================================
208
+
209
+
210
+ def assemble(mo: MetaObject, data: dict[str, object] | None) -> object:
211
+ """Assemble a extracted ``dict`` (the engine's forgiving tree) into a populated
212
+ object graph described by ``mo``.
213
+
214
+ ``mo.new_instance()`` yields the bound type (or a ``ValueObject``) with the
215
+ back-ref already set. Each field's value is written via the ``MetaField`` SPI:
216
+
217
+ * scalar / enum / scalar-array / enum-array → ``set_value`` (engine already
218
+ coerced; arrays arrive as a list);
219
+ * OBJECT non-array → recursively assemble the child ``dict``, then ``set_value``;
220
+ * OBJECT array → recursively assemble each element ``dict`` into a list, then
221
+ ``set_value``.
222
+
223
+ Cycle/depth-guarded identically to :func:`extract_schema_for`. NEVER raises on
224
+ lost/malformed data — those were classified in the report during the engine pass.
225
+ """
226
+ return _assemble(mo, data, set(), 0)
227
+
228
+
229
+ def _assemble(
230
+ mo: MetaObject, data: dict[str, object] | None, visited: set[int], depth: int
231
+ ) -> object:
232
+ o = mo.new_instance()
233
+ if data is None:
234
+ return o
235
+ visited.add(id(mo))
236
+ for field in mo.fields():
237
+ name = field.name
238
+ value = data.get(name)
239
+
240
+ is_object_field = (
241
+ field.sub_type == fc.FIELD_SUBTYPE_OBJECT or field.object_ref is not None
242
+ )
243
+ # Enum fields are string-backed scalars/arrays — treat them as scalar assignment.
244
+ if not is_object_field or field.sub_type == fc.FIELD_SUBTYPE_ENUM:
245
+ # scalar / enum / scalar-array / enum-array: the engine already coerced it.
246
+ if value is not None:
247
+ field.set_value(o, value)
248
+ continue
249
+
250
+ # Nested object — guard cycles/depth (mirror the schema guard).
251
+ ref = _resolve_object_ref(field)
252
+ cyclic_or_deep = (
253
+ ref is None or id(ref) in visited or depth + 1 >= MAX_NEST_DEPTH
254
+ )
255
+
256
+ if _is_array(field):
257
+ # Array-of-objects: map each element dict -> assembled child.
258
+ if isinstance(value, list) and ref is not None and not cyclic_or_deep:
259
+ children: list[object] = [
260
+ _assemble(ref, elem, visited, depth + 1)
261
+ for elem in value
262
+ if isinstance(elem, dict)
263
+ ]
264
+ field.set_value(o, children)
265
+ elif isinstance(value, list):
266
+ # ref unresolved / cyclic / over-deep: empty list (no recursion).
267
+ field.set_value(o, [])
268
+ # absent array stays absent (the engine reports it).
269
+ else:
270
+ # Single object.
271
+ if ref is not None and not cyclic_or_deep and isinstance(value, dict):
272
+ child = _assemble(ref, value, visited, depth + 1)
273
+ field.set_value(o, child)
274
+ # else leave unset.
275
+ visited.discard(id(mo))
276
+ return o
277
+
278
+
279
+ # =============================================================================
280
+ # Field-spec helpers (mirror codegen fr010_field_mapping + the Java/TS reader)
281
+ # =============================================================================
282
+
283
+
284
+ def _scalar_kind(sub_type: str) -> FieldKind:
285
+ """The engine ``FieldKind`` for a scalar field subtype. Unknown subtypes fall
286
+ back to STRING. Mirrors the cross-port ``scalarKind`` ordering."""
287
+ if sub_type in (
288
+ fc.FIELD_SUBTYPE_STRING,
289
+ fc.FIELD_SUBTYPE_UUID,
290
+ fc.FIELD_SUBTYPE_DATE,
291
+ fc.FIELD_SUBTYPE_TIME,
292
+ fc.FIELD_SUBTYPE_TIMESTAMP,
293
+ ):
294
+ return FieldKind.STRING
295
+ if sub_type == fc.FIELD_SUBTYPE_INT:
296
+ return FieldKind.INT
297
+ if sub_type in (fc.FIELD_SUBTYPE_LONG, fc.FIELD_SUBTYPE_CURRENCY):
298
+ return FieldKind.LONG
299
+ if sub_type in (
300
+ fc.FIELD_SUBTYPE_DOUBLE,
301
+ fc.FIELD_SUBTYPE_FLOAT,
302
+ fc.FIELD_SUBTYPE_DECIMAL,
303
+ ):
304
+ return FieldKind.DOUBLE
305
+ if sub_type == fc.FIELD_SUBTYPE_BOOLEAN:
306
+ return FieldKind.BOOLEAN
307
+ return FieldKind.STRING
308
+
309
+
310
+ def _is_array(field: MetaField) -> bool:
311
+ """Array-ness from either form: the node property (programmatic build) or the
312
+ ``@isArray`` attr (how metadata loads from JSON). Mirrors ``fr010_field_mapping``."""
313
+ return bool(field.is_array) or field.attr(KEY_IS_ARRAY) is True
314
+
315
+
316
+ def _is_required(field: MetaField) -> bool:
317
+ """True iff ``@required`` is explicitly the bool ``True`` or the string ``"true"``."""
318
+ v = field.attr(fc.FIELD_ATTR_REQUIRED)
319
+ if v is True:
320
+ return True
321
+ return isinstance(v, str) and v.lower() == "true"
322
+
323
+
324
+ def _text_content(field: MetaField) -> bool:
325
+ """True iff ``@xmlText`` is explicitly the bool ``True`` or the string ``"true"`` —
326
+ the XML text-content extract marker (the field receives its element's text body).
327
+ Mirrors ``_is_required`` and the TS ``xmlText(field)`` own-attr check."""
328
+ v = field.attr(TEMPLATE_ATTR_XML_TEXT)
329
+ if v is True:
330
+ return True
331
+ return isinstance(v, str) and v.lower() == "true"
332
+
333
+
334
+ def _enum_values(field: MetaField) -> list[str]:
335
+ """The string members of an enum field's ``@values`` attr (empty when absent)."""
336
+ v = field.attr(fc.FIELD_ATTR_VALUES)
337
+ if isinstance(v, (list, tuple)):
338
+ return [str(x) for x in v]
339
+ return []
340
+
341
+
342
+ def _enum_aliases(field: MetaField) -> dict[str, str]:
343
+ """The ``@enumAlias`` map of an enum field, or ``{}`` when absent/empty."""
344
+ raw = field.attr(fc.FIELD_ATTR_ENUM_ALIAS)
345
+ if not isinstance(raw, dict):
346
+ return {}
347
+ return {str(k): str(v) for k, v in raw.items() if v is not None}
348
+
349
+
350
+ def _resolve_normalize(field: MetaField, owner: MetaObject | None) -> str:
351
+ """Resolve the enum normalization mode: field-level ``@normalize``, else the
352
+ owning object's ``@normalize``, else the global default (``"strip"``). Mirrors the
353
+ cross-port ``resolveNormalize``."""
354
+ field_mode = _own_attr_string(field, fc.FIELD_ATTR_NORMALIZE)
355
+ if field_mode is not None:
356
+ return field_mode
357
+ if owner is not None:
358
+ owner_mode = _own_attr_string(owner, fc.FIELD_ATTR_NORMALIZE)
359
+ if owner_mode is not None:
360
+ return owner_mode
361
+ return fc.NORMALIZE_DEFAULT
362
+
363
+
364
+ def _own_attr_string(node: MetaData, attr: str) -> str | None:
365
+ """The own (non-inherited) string value of an attr on a node, or ``None`` when
366
+ absent/empty."""
367
+ v = node.attr(attr)
368
+ if isinstance(v, str):
369
+ return v if v else None
370
+ return None if v is None else str(v)
371
+
372
+
373
+ def _resolve_object_ref(field: MetaField) -> MetaObject | None:
374
+ """Resolve a field's ``@objectRef`` to its ``MetaObject`` by walking to the tree
375
+ root and matching on ``resolution_key()`` (the package-folded FQN the objectRef
376
+ uses), with a short-name fallback. Returns ``None`` when unresolvable (→ the
377
+ opaque-leaf / leave-unset guard). Reflection-free: a pure metadata-tree walk."""
378
+ ref = field.object_ref
379
+ if ref is None:
380
+ return None
381
+ root = _root_of(field)
382
+ if root is None:
383
+ return None
384
+ objects = [c for c in root.children() if isinstance(c, MetaObject)]
385
+ direct = next((o for o in objects if o.resolution_key() == ref), None)
386
+ if direct is not None:
387
+ return direct
388
+ # Short-name fallback: a bare ref or the trailing segment after the last "::".
389
+ from metaobjects.shared.separators import PACKAGE_SEP
390
+
391
+ short = ref.rsplit(PACKAGE_SEP, 1)[-1] if PACKAGE_SEP in ref else ref
392
+ return next((o for o in objects if o.name == short), None)
393
+
394
+
395
+ def _root_of(node: MetaData) -> MetaData | None:
396
+ """Walk the parent chain to the tree root (the node whose ``parent`` is None)."""
397
+ cur: MetaData | None = node
398
+ while cur is not None and cur.parent is not None:
399
+ cur = cur.parent
400
+ return cur
@@ -0,0 +1,70 @@
1
+ """ValueObject — the dict-backed default backing object for ``object.value``.
2
+
3
+ Idiomatic Python port of Java's ``ValueObject`` / the TS ``ValueObject``: a
4
+ string-keyed mapping that holds its ``MetaObject`` back-reference and supports
5
+ both declared fields and arbitrary overflow keys. THE unbound default produced
6
+ by ``MetaObject.new_instance()`` when no native type is registered for the
7
+ object's resolution key. Reflection-free.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Optional
12
+
13
+ if TYPE_CHECKING:
14
+ from .meta_object import MetaObject
15
+
16
+
17
+ class ValueObject:
18
+ """A dict-backed backing object — declared fields AND overflow keys.
19
+
20
+ Implements the ``MetaObjectAware`` contract structurally
21
+ (``get_meta_data`` / ``set_meta_data``) without importing the protocol.
22
+ """
23
+
24
+ def __init__(self, mo: "Optional[MetaObject]" = None) -> None:
25
+ """Construct over an optional MetaObject back-reference (set at new_instance)."""
26
+ self._values: dict[str, object] = {}
27
+ self._meta_data: Optional[MetaObject] = mo
28
+
29
+ # -- MetaObjectAware ------------------------------------------------
30
+
31
+ def get_meta_data(self) -> "Optional[MetaObject]":
32
+ """The MetaObject describing this instance, or None if not attached."""
33
+ return self._meta_data
34
+
35
+ def set_meta_data(self, mo: "MetaObject") -> None:
36
+ """Attach the MetaObject describing this instance (back-reference)."""
37
+ self._meta_data = mo
38
+
39
+ # -- dict-backed value access (declared fields + overflow keys) -----
40
+
41
+ def get(self, name: str) -> object:
42
+ """Read a value by key. Returns None for an unset key."""
43
+ return self._values.get(name)
44
+
45
+ def set(self, name: str, value: object) -> None:
46
+ """Write a value by key. Any string key is accepted (overflow-friendly)."""
47
+ self._values[name] = value
48
+
49
+ def has(self, name: str) -> bool:
50
+ """True if the key has been set."""
51
+ return name in self._values
52
+
53
+ def delete(self, name: str) -> bool:
54
+ """Remove a key; True if it was present."""
55
+ if name in self._values:
56
+ del self._values[name]
57
+ return True
58
+ return False
59
+
60
+ def keys(self) -> list[str]:
61
+ """Insertion-ordered keys currently held."""
62
+ return list(self._values.keys())
63
+
64
+ def to_dict(self) -> dict[str, object]:
65
+ """A shallow-copy snapshot of the backing map (insertion-ordered)."""
66
+ return dict(self._values)
67
+
68
+ def __repr__(self) -> str:
69
+ mo = self._meta_data.name if self._meta_data is not None else None
70
+ return f"ValueObject(meta={mo!r}, {self._values!r})"
File without changes
@@ -0,0 +1,180 @@
1
+ """M:N junction FK derivation — the single source of truth for which junction
2
+ columns are the SOURCE side and the TARGET side of a many-to-many relationship.
3
+
4
+ A M:N relationship (``@cardinality: "many"``, ``@objectRef: <target>``,
5
+ ``@through: <junction>``) does NOT restate its FK columns. They are derived from
6
+ the junction entity's two ``identity.reference`` children — one resolving to the
7
+ source entity, one to the target — exactly as 1:N FK direction is declared.
8
+
9
+ Three modes (see the FR-018 design + the TS reference ``derive-m2m-fields.ts``):
10
+ 1. Hetero (source != target): the reference resolving to the source entity
11
+ gives source_field; the one resolving to the target gives target_field.
12
+ 2. Directed self-join (source == target, @sourceRefField set): both references
13
+ resolve to the same entity, so @sourceRefField names the source-side FK
14
+ field; the OTHER reference is the target side.
15
+ 3. Symmetric self-join (source == target, @symmetric: true): undirected; the
16
+ two references are taken in declaration order (source_field = first,
17
+ target_field = second). Resolution unions both at read time.
18
+ Ambiguous (source == target, neither @sourceRefField nor @symmetric) → raise.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+ from ...meta_data import MetaData
25
+ from ....shared.base_types import TYPE_IDENTITY
26
+ from ....shared.separators import PACKAGE_SEP
27
+ from ..identity.identity_constants import (
28
+ IDENTITY_ATTR_FIELDS,
29
+ IDENTITY_REFERENCE_ATTR_REFERENCES,
30
+ IDENTITY_SUBTYPE_REFERENCE,
31
+ )
32
+ from .meta_relationship import MetaRelationship
33
+
34
+
35
+ class M2MDerivationError(Exception):
36
+ """Raised when a M:N relationship's junction FK fields cannot be derived."""
37
+
38
+ code = "ERR_INVALID_RELATIONSHIP"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class M2MFields:
43
+ """The derived source/target junction FK fields for a M:N relationship."""
44
+
45
+ source_field: str
46
+ target_field: str
47
+
48
+
49
+ def _strip_package(name: str) -> str:
50
+ """Return the bare object name from a (possibly package-qualified) reference."""
51
+ idx = name.rfind(PACKAGE_SEP)
52
+ return name[idx + len(PACKAGE_SEP):] if idx >= 0 else name
53
+
54
+
55
+ def _reference_children(junction: MetaData) -> list[MetaData]:
56
+ """The junction's own identity.reference children (declaration order)."""
57
+ return [
58
+ c
59
+ for c in junction.own_children()
60
+ if c.type == TYPE_IDENTITY and c.sub_type == IDENTITY_SUBTYPE_REFERENCE
61
+ ]
62
+
63
+
64
+ def _ref_fk_field(ref: MetaData) -> str | None:
65
+ """First @fields entry of a reference (the physical FK column on the junction)."""
66
+ fields = ref.attr(IDENTITY_ATTR_FIELDS)
67
+ if isinstance(fields, (list, tuple)) and fields:
68
+ first = fields[0]
69
+ return first if isinstance(first, str) else None
70
+ if isinstance(fields, str) and fields:
71
+ return fields.split(",")[0].strip() or None
72
+ return None
73
+
74
+
75
+ def _ref_target_entity(ref: MetaData) -> str | None:
76
+ """The @references target-entity name of a reference (bare, package-stripped)."""
77
+ v = ref.attr(IDENTITY_REFERENCE_ATTR_REFERENCES)
78
+ return _strip_package(v) if isinstance(v, str) and v else None
79
+
80
+
81
+ def derive_m2m_fields(
82
+ rel: MetaRelationship,
83
+ source: MetaData,
84
+ object_index: dict[str, MetaData],
85
+ ) -> M2MFields:
86
+ """Derive the source/target junction FK fields for a M:N relationship.
87
+
88
+ *object_index* is a bare-name → object map of the loaded model's top-level
89
+ objects (the Python loader's resolution surface; mirrors the TS
90
+ ``root.findObject``). Raises :class:`M2MDerivationError` when the junction is
91
+ missing/malformed or the self-join is ambiguous.
92
+ """
93
+ through_name = rel.through()
94
+ if through_name is None:
95
+ raise M2MDerivationError(
96
+ f'relationship "{source.name}.{rel.name}" is missing @through '
97
+ f"(required for M:N derivation)"
98
+ )
99
+ junction = object_index.get(_strip_package(through_name))
100
+ if junction is None:
101
+ raise M2MDerivationError(
102
+ f'relationship "{source.name}.{rel.name}" @through "{through_name}" '
103
+ f"does not resolve to an entity"
104
+ )
105
+
106
+ target_name = rel.object_ref()
107
+ if target_name is None:
108
+ raise M2MDerivationError(
109
+ f'relationship "{source.name}.{rel.name}" is missing @objectRef '
110
+ f"(the M:N target)"
111
+ )
112
+
113
+ refs = _reference_children(junction)
114
+ if len(refs) != 2:
115
+ raise M2MDerivationError(
116
+ f'junction "{through_name}" for relationship "{source.name}.{rel.name}" '
117
+ f"must declare exactly two identity.reference children "
118
+ f"(found {len(refs)})"
119
+ )
120
+
121
+ is_self_join = _strip_package(target_name) == source.name
122
+
123
+ if not is_self_join:
124
+ # Hetero: match each reference by the entity it resolves to.
125
+ source_ref = next(
126
+ (r for r in refs if _ref_target_entity(r) == source.name), None
127
+ )
128
+ target_ref = next(
129
+ (r for r in refs if _ref_target_entity(r) == _strip_package(target_name)),
130
+ None,
131
+ )
132
+ source_field = _ref_fk_field(source_ref) if source_ref is not None else None
133
+ target_field = _ref_fk_field(target_ref) if target_ref is not None else None
134
+ if source_field is None or target_field is None:
135
+ raise M2MDerivationError(
136
+ f'junction "{through_name}" for relationship '
137
+ f'"{source.name}.{rel.name}" must declare one identity.reference '
138
+ f'to "{source.name}" and one to "{_strip_package(target_name)}"'
139
+ )
140
+ return M2MFields(source_field=source_field, target_field=target_field)
141
+
142
+ # Self-join: both references resolve to the same entity.
143
+ if rel.symmetric():
144
+ # Undirected: take references in declaration order; union at read time.
145
+ a = _ref_fk_field(refs[0])
146
+ b = _ref_fk_field(refs[1])
147
+ if a is None or b is None:
148
+ raise M2MDerivationError(
149
+ f'symmetric junction "{through_name}" for '
150
+ f'"{source.name}.{rel.name}" has a reference with no @fields'
151
+ )
152
+ return M2MFields(source_field=a, target_field=b)
153
+
154
+ source_ref_field = rel.source_ref_field()
155
+ if source_ref_field is None:
156
+ raise M2MDerivationError(
157
+ f'self-join relationship "{source.name}.{rel.name}" through '
158
+ f'"{through_name}" is ambiguous: set @sourceRefField (directed) or '
159
+ f"@symmetric (undirected)"
160
+ )
161
+
162
+ # Directed self-join: @sourceRefField names the source-side FK; the other
163
+ # reference is the target side.
164
+ source_ref = next(
165
+ (r for r in refs if _ref_fk_field(r) == source_ref_field), None
166
+ )
167
+ if source_ref is None:
168
+ raise M2MDerivationError(
169
+ f'@sourceRefField "{source_ref_field}" on "{source.name}.{rel.name}" '
170
+ f"does not match any identity.reference FK field on junction "
171
+ f'"{through_name}"'
172
+ )
173
+ target_ref = next((r for r in refs if r is not source_ref), None)
174
+ target_field = _ref_fk_field(target_ref) if target_ref is not None else None
175
+ if target_field is None:
176
+ raise M2MDerivationError(
177
+ f'junction "{through_name}" for "{source.name}.{rel.name}" has no '
178
+ f"distinct target-side reference"
179
+ )
180
+ return M2MFields(source_field=source_ref_field, target_field=target_field)