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,672 @@
1
+ """Payload-VO codegen — one ``<template_name_snake>_payload.py`` per declared
2
+ ``template.*`` (prompt / output / toolcall).
3
+
4
+ FR-006 (Python) — cross-port parity with the Kotlin ``KotlinPayloadGenerator``
5
+ (server/java/codegen-kotlin), C# ``MetaObjects.Codegen.PayloadVoGenerator``, and
6
+ TS payload-VO emit. The Python output-parser generator (FR-006) was originally
7
+ self-contained — it embedded its Pydantic model inline pending a payload-VO
8
+ generator. With this module shipping, the parser generator switches to an
9
+ import-style emit so a single payload class is reused by both prompt rendering
10
+ and output parsing (matches the Java payload-VO ↔ Java output-parser handoff).
11
+
12
+ Each generated file declares a Pydantic v2 ``BaseModel`` per template. Field
13
+ types are origin-aware: a payload-VO field may carry an ``origin.*`` child that
14
+ declares how the value is derived, and the field's annotation is resolved as:
15
+
16
+ * ``origin.passthrough`` (``@from "Entity.field"``) — type of the source field.
17
+ * ``origin.aggregate`` (``@agg count``) — ``int``.
18
+ (``@agg avg``) — ``float``.
19
+ (``@agg sum``/``min``/``max``) — type of ``@of`` field.
20
+ * ``origin.collection`` (``@via "Parent.relName"``) — ``list[<TargetShortName>Payload]``,
21
+ and the nested ``<TargetShortName>Payload`` is emitted into the SAME file
22
+ (so callers ``from .<template>_payload import …`` once). Within one file,
23
+ the nested class is emitted exactly once even if multiple fields reference
24
+ the same target (per-file dedupe — see the Dedupe note below).
25
+ * No origin child — fall back to ``type_map.py_type_for(field)``.
26
+
27
+ Generated file naming mirrors the output-parser convention:
28
+ ``<snake_case(template_name)>_payload.py`` and the public model class is
29
+ ``<template_name>Payload``. Resolution of ``@payloadRef`` to the underlying
30
+ ``object.value`` is short-name based (same contract as the Kotlin reference).
31
+
32
+ Dedupe note: the nested-payload dedupe is **per-file**, not per-run. Each
33
+ template's payload module is self-contained, so when two templates reference
34
+ the same `origin.collection` target, both files emit `PostPayload`. This
35
+ differs from Kotlin's cross-run dedupe — Kotlin emits each class to its OWN
36
+ `.kt` file (one-class-per-file via KotlinPoet), so a single `PostPayload.kt`
37
+ is enough; subsequent templates merely import it. Python's per-template
38
+ file emit makes cross-run dedupe a footgun (the second template would
39
+ reference an undefined `PostPayload` class), so each file owns its full
40
+ class graph.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ from collections.abc import Callable
45
+
46
+ from metaobjects.codegen.constants import generated_header
47
+ from metaobjects.codegen.format import ruff_format
48
+ from metaobjects.codegen import type_map
49
+ from metaobjects.codegen.generator import EmittedFile, GenContext, Generator
50
+ from metaobjects.codegen.type_map import py_type_for
51
+ from metaobjects.meta.core.field import field_constants as fc
52
+ from metaobjects.meta.core.field.meta_field import MetaField
53
+ from metaobjects.meta.core.object.meta_object import MetaObject
54
+ from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_VALUE
55
+ from metaobjects.meta.core.relationship.meta_relationship import MetaRelationship
56
+ from metaobjects.meta.meta_data import MetaData
57
+ from metaobjects.meta.persistence.origin.meta_origin import MetaOrigin
58
+ from metaobjects.meta.persistence.origin.origin_constants import (
59
+ ORIGIN_ATTR_AGG,
60
+ ORIGIN_ATTR_FROM,
61
+ ORIGIN_ATTR_OF,
62
+ ORIGIN_ATTR_VIA,
63
+ )
64
+ from metaobjects.meta.template import template_constants as tc
65
+ from metaobjects.meta.template.meta_template import MetaTemplate
66
+ from metaobjects.shared.base_types import TYPE_OBJECT, TYPE_TEMPLATE
67
+ from metaobjects.shared.separators import PACKAGE_SEP
68
+
69
+ _GENERATOR_NAME = "payload-vo-generator"
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Naming helpers (snake_case mirrors the router/output-parser local helpers).
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ def _snake_case(name: str) -> str:
78
+ """``NpcResponseOutput`` → ``npc_response_output``. PascalCase → snake_case
79
+ with no acronym handling — matches the convention used by sibling generators."""
80
+ out: list[str] = []
81
+ for i, ch in enumerate(name):
82
+ if ch.isupper() and i > 0:
83
+ out.append("_")
84
+ out.append(ch.lower())
85
+ return "".join(out)
86
+
87
+
88
+ def payload_class_name(template_name: str) -> str:
89
+ """``NpcResponseOutput`` → ``NpcResponseOutputPayload``.
90
+
91
+ Mirrors Kotlin's ``templateShort + "Payload"`` and gives the output-parser
92
+ generator a single, stable class name to import."""
93
+ return f"{template_name}Payload"
94
+
95
+
96
+ def payload_module_name(template_name: str) -> str:
97
+ """``NpcResponseOutput`` → ``npc_response_output_payload``. The module name
98
+ used in the emitted file path AND the import statement from the parser."""
99
+ return f"{_snake_case(template_name)}_payload"
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Resolution helpers (lookup by short-name OR FQN — same contract as Kotlin).
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ def _resolve_object_by_short_or_fqn(root: MetaData, ref: str) -> MetaObject | None:
108
+ """Find a ``MetaObject`` child by short-name match.
109
+
110
+ Equivalent contract to ``KotlinGenUtil.resolveObjectByShortOrFqn``, but
111
+ simpler in Python: ``MetaData.name`` only ever holds the short name (the
112
+ package lives on ``MetaData.package`` and ``fqn()`` builds the dotted
113
+ form on demand). So a plain ``child.name == ref`` is enough.
114
+
115
+ ``ref`` is the value passed in ``@payloadRef`` / ``@objectRef`` /
116
+ ``origin.@from``, which by spec is the short name of the target object."""
117
+ for child in root.own_children():
118
+ if child.type != TYPE_OBJECT or not isinstance(child, MetaObject):
119
+ continue
120
+ if child.name == ref:
121
+ return child
122
+ return None
123
+
124
+
125
+ def resolve_payload_vo(root: MetaData, ref: str) -> MetaObject | None:
126
+ """Resolve a ``@payloadRef`` to its ``object.value``. Rejects entities —
127
+ payloads MUST be value-objects (same contract as Kotlin)."""
128
+ obj = _resolve_object_by_short_or_fqn(root, ref)
129
+ if obj is None or obj.sub_type != OBJECT_SUBTYPE_VALUE:
130
+ return None
131
+ return obj
132
+
133
+
134
+ def _split_dotted_ref(ref: str) -> tuple[str, str] | None:
135
+ """``"Entity.field"`` → ``("Entity", "field")``. Returns ``None`` for
136
+ no-dot / leading-dot / trailing-dot — same contract as Kotlin."""
137
+ dot = ref.find(".")
138
+ if dot <= 0 or dot >= len(ref) - 1:
139
+ return None
140
+ return ref[:dot], ref[dot + 1 :]
141
+
142
+
143
+ def _resolve_dotted_field_ref(root: MetaData, dotted_ref: str) -> MetaField | None:
144
+ """Resolve ``"Entity.field"`` to the ``MetaField`` on ``Entity`` (by short
145
+ name OR FQN-trailing-segment match). Returns ``None`` if either half fails."""
146
+ parts = _split_dotted_ref(dotted_ref)
147
+ if parts is None:
148
+ return None
149
+ entity_name, field_name = parts
150
+ obj = _resolve_object_by_short_or_fqn(root, entity_name)
151
+ if obj is None:
152
+ return None
153
+ for f in obj.fields():
154
+ if not isinstance(f, MetaField):
155
+ continue
156
+ if f.name == field_name:
157
+ return f
158
+ return None
159
+
160
+
161
+ def is_field_required(field: MetaField) -> bool:
162
+ """The single required-ness predicate the payload model uses to decide a field's
163
+ optionality (``T`` vs ``T | None = None``). A field is required iff its OWN
164
+ ``@required`` attr is the boolean ``True`` — matching the TS payload-codegen
165
+ ``isFieldRequired`` (``ownAttr === true``) so the extract-tier mapper that
166
+ constructs this payload can rely on the same boundary (no skew). A
167
+ ``@required: "true"`` string therefore types optional in BOTH the payload and the
168
+ mapper. The extractor generator imports THIS predicate.
169
+
170
+ Note: this intentionally accepts ONLY the boolean ``True`` (matching the TS
171
+ payload-codegen predicate), which DELIBERATELY differs from the runtime
172
+ ``object_extract._is_required`` / ``fr010_field_mapping.is_required``, both of which
173
+ additionally treat the string ``"true"`` as required. The payload type's optionality
174
+ and the extractor mapper's None-guarding are kept in lockstep by sharing THIS
175
+ predicate, so do not "reconcile" it with the runtime predicate."""
176
+ return field.attr(fc.FIELD_ATTR_REQUIRED) is True
177
+
178
+
179
+ def _resolve_object_field_type(
180
+ field: MetaField,
181
+ root: MetaData,
182
+ nested_emit_queue: list[tuple[MetaObject, str]],
183
+ emitted_nested_fqns: set[str],
184
+ ) -> tuple[str, set[str]]:
185
+ """A plain ``field.object`` (``@objectRef``, no origin child) — resolve to the
186
+ nested ``<TargetShortName>Payload`` (single) or ``list[<TargetShortName>Payload]``
187
+ (array). The target VO is scheduled for in-file emission (per-file dedupe, same
188
+ mechanism as ``origin.collection``). Falls back to the bare type-map form when the
189
+ ``@objectRef`` can't be resolved (defensive — loader validation gates it first)."""
190
+ ref = field.attr(fc.FIELD_ATTR_OBJECT_REF)
191
+ if not isinstance(ref, str) or not ref:
192
+ return _fallback_type(field)
193
+ target = _resolve_object_by_short_or_fqn(root, ref)
194
+ if target is None and PACKAGE_SEP in ref:
195
+ target = _resolve_object_by_short_or_fqn(root, ref.rsplit(PACKAGE_SEP, 1)[-1])
196
+ if target is None:
197
+ return _fallback_type(field)
198
+ nested_class = payload_class_name(target.name)
199
+ target_fqn = target.fqn()
200
+ if target_fqn not in emitted_nested_fqns:
201
+ emitted_nested_fqns.add(target_fqn)
202
+ nested_emit_queue.append((target, nested_class))
203
+ if type_map.field_is_array(field):
204
+ return f"list[{nested_class}]", set()
205
+ return nested_class, set()
206
+
207
+
208
+ def _pascal(name: str) -> str:
209
+ """``priority`` → ``Priority``; ``order_priority`` is left as-is segment-wise
210
+ (only the leading char is upper-cased), matching the cross-port naming rule which
211
+ PascalCases the bare field / super name (no snake-splitting)."""
212
+ return name[:1].upper() + name[1:] if name else name
213
+
214
+
215
+ def _shared_enum_super(field: MetaField) -> MetaData | None:
216
+ """The abstract ``field.enum`` super a field extends, or ``None``. A field whose
217
+ ``@values`` is inherited from an abstract base enum collapses (cross-port) to a
218
+ NAMED module alias keyed on the SUPER's name, so multiple fields sharing one
219
+ abstract enum reuse a single ``<Super> = Literal[...]`` alias. An inline enum
220
+ (no super) types inline."""
221
+ sup = field.super_data
222
+ if sup is not None and sup.sub_type == fc.FIELD_SUBTYPE_ENUM:
223
+ return sup
224
+ return None
225
+
226
+
227
+ def _enum_field_type(
228
+ field: MetaField, enum_aliases: dict[str, str]
229
+ ) -> tuple[str, set[str]] | None:
230
+ """Resolve a ``field.enum`` annotation. Returns ``None`` for a non-enum field (the
231
+ caller falls through to the generic path).
232
+
233
+ * SHARED (extends an abstract ``field.enum``) → emit/reuse a module-level
234
+ ``<Pascal(super.name)> = Literal[...]`` alias (deduped in *enum_aliases*) and
235
+ reference it (``<Alias>`` / ``list[<Alias>]``).
236
+ * INLINE (no super) → inline ``Literal[...]`` via ``py_type_for`` (no alias).
237
+ * No effective ``@values`` → fall through to ``py_type_for`` (bare ``str``).
238
+ """
239
+ if field.sub_type != fc.FIELD_SUBTYPE_ENUM:
240
+ return None
241
+ values = type_map.effective_enum_values(field)
242
+ if not values:
243
+ pt = py_type_for(field)
244
+ return pt.expr, set(pt.imports)
245
+ sup = _shared_enum_super(field)
246
+ if sup is None:
247
+ # Inline enum — let py_type_for emit the inline Literal[...] (+ the import).
248
+ pt = py_type_for(field)
249
+ return pt.expr, set(pt.imports)
250
+ alias = _pascal(sup.name)
251
+ if alias not in enum_aliases:
252
+ members = ", ".join(type_map._py_str_literal(v) for v in values)
253
+ enum_aliases[alias] = f"Literal[{members}]"
254
+ ref = f"list[{alias}]" if type_map.field_is_array(field) else alias
255
+ return ref, {"from typing import Literal"}
256
+
257
+
258
+ def _find_origin_child(field: MetaField) -> MetaOrigin | None:
259
+ """First ``origin.*`` child of *field* (own children only — origins are
260
+ declared inline; there's no inheritance contract for them today)."""
261
+ for c in field.own_children():
262
+ if isinstance(c, MetaOrigin):
263
+ return c
264
+ return None
265
+
266
+
267
+ def _find_relationship_on(obj: MetaObject, rel_name: str) -> MetaRelationship | None:
268
+ """Resolve a relationship by name on *obj*. Walks effective children so
269
+ inherited relationships are visible."""
270
+ for c in obj.children():
271
+ if isinstance(c, MetaRelationship) and c.name == rel_name:
272
+ return c
273
+ return None
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Origin-aware field-type resolution.
278
+ # ---------------------------------------------------------------------------
279
+
280
+
281
+ def _resolve_passthrough_type(
282
+ origin: MetaOrigin, root: MetaData, fallback: MetaField
283
+ ) -> tuple[str, set[str]]:
284
+ """``origin.passthrough @from "Entity.field"`` — resolve to source field's
285
+ Python type. Falls back to the payload field's own type when the dotted
286
+ ref can't be resolved (defensive — loader validation already gates ``@from``)."""
287
+ from_ref = origin.attr(ORIGIN_ATTR_FROM)
288
+ if not isinstance(from_ref, str) or not from_ref:
289
+ return _fallback_type(fallback)
290
+ source = _resolve_dotted_field_ref(root, from_ref)
291
+ if source is None:
292
+ return _fallback_type(fallback)
293
+ pt = py_type_for(source)
294
+ return pt.expr, set(pt.imports)
295
+
296
+
297
+ def _resolve_aggregate_type(
298
+ origin: MetaOrigin, root: MetaData, fallback: MetaField
299
+ ) -> tuple[str, set[str]]:
300
+ """``origin.aggregate``: type rule —
301
+ - count → ``int``
302
+ - avg → ``float``
303
+ - sum / min / max → type of the ``@of`` field
304
+ """
305
+ agg = origin.attr(ORIGIN_ATTR_AGG)
306
+ if agg == "count":
307
+ return "int", set()
308
+ if agg == "avg":
309
+ return "float", set()
310
+ if agg in ("sum", "min", "max"):
311
+ of_ref = origin.attr(ORIGIN_ATTR_OF)
312
+ if not isinstance(of_ref, str) or not of_ref:
313
+ return _fallback_type(fallback)
314
+ source = _resolve_dotted_field_ref(root, of_ref)
315
+ if source is None:
316
+ return _fallback_type(fallback)
317
+ pt = py_type_for(source)
318
+ return pt.expr, set(pt.imports)
319
+ return _fallback_type(fallback)
320
+
321
+
322
+ def _resolve_collection_type(
323
+ origin: MetaOrigin,
324
+ root: MetaData,
325
+ fallback: MetaField,
326
+ nested_emit_queue: list[tuple[MetaObject, str]],
327
+ emitted_nested_fqns: set[str],
328
+ ) -> tuple[str, set[str]]:
329
+ """``origin.collection @via "Parent.rel"`` — walk Parent's relationship to
330
+ its ``@objectRef`` target, schedule a nested ``<TargetShortName>Payload``
331
+ for in-file emission, return ``list[<TargetShortName>Payload]``.
332
+
333
+ Dedupe is per-file via *emitted_nested_fqns* — if the same target is
334
+ referenced by two fields in the same payload module, only one nested
335
+ class is emitted. Cross-file dedupe would leave forward-references
336
+ dangling (see the module docstring)."""
337
+ via = origin.attr(ORIGIN_ATTR_VIA)
338
+ if not isinstance(via, str) or not via:
339
+ return _fallback_type(fallback)
340
+ parts = _split_dotted_ref(via)
341
+ if parts is None:
342
+ return _fallback_type(fallback)
343
+ parent_name, rel_name = parts
344
+ parent = _resolve_object_by_short_or_fqn(root, parent_name)
345
+ if parent is None:
346
+ return _fallback_type(fallback)
347
+ rel = _find_relationship_on(parent, rel_name)
348
+ if rel is None:
349
+ return _fallback_type(fallback)
350
+ target_ref = rel.object_ref()
351
+ if not target_ref:
352
+ return _fallback_type(fallback)
353
+ target = _resolve_object_by_short_or_fqn(root, target_ref)
354
+ if target is None:
355
+ return _fallback_type(fallback)
356
+ # MetaData.name is the short name (no `::`); see _resolve_object_by_short_or_fqn.
357
+ nested_class = payload_class_name(target.name)
358
+ target_fqn = target.fqn()
359
+ if target_fqn not in emitted_nested_fqns:
360
+ emitted_nested_fqns.add(target_fqn)
361
+ nested_emit_queue.append((target, nested_class))
362
+ return f"list[{nested_class}]", set()
363
+
364
+
365
+ def _fallback_type(field: MetaField) -> tuple[str, set[str]]:
366
+ """Type-map fallback used by every origin path when resolution fails."""
367
+ pt = py_type_for(field)
368
+ return pt.expr, set(pt.imports)
369
+
370
+
371
+ def _resolve_field_type(
372
+ field: MetaField,
373
+ root: MetaData,
374
+ nested_emit_queue: list[tuple[MetaObject, str]],
375
+ emitted_nested_fqns: set[str],
376
+ enum_aliases: dict[str, str],
377
+ ) -> tuple[str, set[str]]:
378
+ """Resolve the Python annotation for one payload-VO field, honoring any
379
+ ``origin.*`` child. Falls back to ``type_map.py_type_for`` when none.
380
+
381
+ Note: a payload-VO field declared as ``field.object`` (with ``@objectRef``)
382
+ but no origin child falls through to ``type_map.py_type_for``, which emits
383
+ the entity short-name as a forward-reference string. The entity model is
384
+ NOT auto-imported — payload modules and entity modules may live in
385
+ different output directories, and the consumer is expected to wire
386
+ cross-module imports explicitly. The metadata-driven path for "this VO
387
+ field is a foreign-object value" is ``origin.collection`` (nested payload)
388
+ or ``origin.passthrough`` (scalar projection)."""
389
+ origin = _find_origin_child(field)
390
+ if origin is None:
391
+ # A plain ``field.object`` (``@objectRef``, no origin) → nested payload class
392
+ # (single or list), emitted in the same file. This is the prompt-pillar
393
+ # nested-payload case the extract tier maps onto; without it the payload would
394
+ # reference an undefined bare entity name (Pydantic "not fully defined").
395
+ if field.sub_type == fc.FIELD_SUBTYPE_OBJECT:
396
+ return _resolve_object_field_type(
397
+ field, root, nested_emit_queue, emitted_nested_fqns
398
+ )
399
+ # A ``field.enum`` → Literal[...] (inline) or a named module alias (shared).
400
+ enum_type = _enum_field_type(field, enum_aliases)
401
+ if enum_type is not None:
402
+ return enum_type
403
+ pt = py_type_for(field)
404
+ return pt.expr, set(pt.imports)
405
+ if origin.sub_type == "passthrough":
406
+ return _resolve_passthrough_type(origin, root, field)
407
+ if origin.sub_type == "aggregate":
408
+ return _resolve_aggregate_type(origin, root, field)
409
+ if origin.sub_type == "collection":
410
+ return _resolve_collection_type(
411
+ origin, root, field, nested_emit_queue, emitted_nested_fqns
412
+ )
413
+ return _fallback_type(field)
414
+
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # Class-block emission.
418
+ # ---------------------------------------------------------------------------
419
+
420
+
421
+ def _emit_payload_class(
422
+ class_name: str,
423
+ payload_vo: MetaObject,
424
+ root: MetaData,
425
+ nested_emit_queue: list[tuple[MetaObject, str]],
426
+ emitted_nested_fqns: set[str],
427
+ extra_imports: set[str],
428
+ enum_aliases: dict[str, str],
429
+ docstring: str,
430
+ ) -> list[str]:
431
+ """Build the source lines for one Pydantic ``BaseModel`` subclass."""
432
+ lines: list[str] = [f"class {class_name}(BaseModel):", f' """{docstring}"""']
433
+ field_lines: list[str] = []
434
+ for field in payload_vo.fields():
435
+ if not isinstance(field, MetaField):
436
+ continue
437
+ annotation, imports = _resolve_field_type(
438
+ field, root, nested_emit_queue, emitted_nested_fqns, enum_aliases
439
+ )
440
+ extra_imports.update(imports)
441
+ # Optionality mirrors the cross-port (TS) payload-codegen: a ``@required``
442
+ # field is non-optional ``T``; everything else is ``T | None = None`` so the
443
+ # strict payload can carry an absent optional value (and the extract-tier
444
+ # mapper, which shares ``is_field_required``, agrees on the boundary).
445
+ if is_field_required(field):
446
+ field_lines.append(f" {field.name}: {annotation}")
447
+ else:
448
+ field_lines.append(f" {field.name}: {annotation} | None = None")
449
+ if field_lines:
450
+ lines.extend(field_lines)
451
+ else:
452
+ lines.append(" pass")
453
+ return lines
454
+
455
+
456
+ def render_payload_vo(
457
+ template: MetaTemplate,
458
+ root: MetaData,
459
+ *,
460
+ generator: "PayloadVoGenerator | None" = None,
461
+ ) -> str | None:
462
+ """Render one payload module for a ``template.*`` node.
463
+
464
+ When *generator* is supplied, its ``_emit_payload_class`` override is used for
465
+ each emitted class (the extension seam). When ``None`` (the module-level
466
+ back-compat call path), the module-level :func:`_emit_payload_class` is used —
467
+ output is byte-identical to the pre-refactor behavior.
468
+
469
+ Returns ``None`` when the ``@payloadRef`` can't be resolved to an
470
+ ``object.value`` (defensive — the loader validation pass normally catches
471
+ this first).
472
+
473
+ Nested-payload dedupe is per-file: if the same collection target appears
474
+ twice within one payload module (e.g. two fields both `origin.collection`
475
+ on the same relationship), only one nested class is emitted. Across
476
+ different templates, each file owns its full class graph independently —
477
+ see the module docstring for the rationale."""
478
+ payload_ref = template.attr(tc.TEMPLATE_ATTR_PAYLOAD_REF)
479
+ if not isinstance(payload_ref, str) or not payload_ref:
480
+ return None
481
+ payload = resolve_payload_vo(root, payload_ref)
482
+ if payload is None:
483
+ return None
484
+
485
+ # Per-file dedupe set: scoped to this single render call so each emitted
486
+ # module is self-contained (no cross-template forward references).
487
+ emitted_nested_fqns: set[str] = set()
488
+ class_name = payload_class_name(template.name)
489
+ extra_imports: set[str] = set()
490
+ nested_emit_queue: list[tuple[MetaObject, str]] = []
491
+ # Shared-enum aliases (``<Pascal(super.name)> = Literal[...]``), deduped by name and
492
+ # emitted once at module scope before the classes that reference them.
493
+ enum_aliases: dict[str, str] = {}
494
+
495
+ # The class-block emitter: the generator's overridable hook when an instance is
496
+ # supplied, else the module-level default (byte-identical back-compat path).
497
+ emit_class = generator._emit_payload_class if generator is not None else _emit_payload_class
498
+
499
+ # The PRIMARY class (the one named after the template). Its docstring
500
+ # mirrors Kotlin's KDoc.
501
+ primary_block = emit_class(
502
+ class_name=class_name,
503
+ payload_vo=payload,
504
+ root=root,
505
+ nested_emit_queue=nested_emit_queue,
506
+ emitted_nested_fqns=emitted_nested_fqns,
507
+ extra_imports=extra_imports,
508
+ enum_aliases=enum_aliases,
509
+ docstring=(
510
+ f"GENERATED payload for template ``{template.name}``.\n\n"
511
+ f" Field shape derived from the ``{payload.name}`` object.value."
512
+ ),
513
+ )
514
+
515
+ # NESTED classes scheduled by origin.collection. Drain the queue
516
+ # iteratively so nested-of-nested chains also get emitted (Kotlin behaves
517
+ # the same way — the recursive emit is queue-driven here for clarity).
518
+ nested_blocks: list[list[str]] = []
519
+ nested_class_names: list[str] = []
520
+ while nested_emit_queue:
521
+ target, nested_class = nested_emit_queue.pop(0)
522
+ block = emit_class(
523
+ class_name=nested_class,
524
+ payload_vo=target,
525
+ root=root,
526
+ nested_emit_queue=nested_emit_queue,
527
+ emitted_nested_fqns=emitted_nested_fqns,
528
+ extra_imports=extra_imports,
529
+ enum_aliases=enum_aliases,
530
+ docstring=(
531
+ f"GENERATED nested payload for collection target ``{target.name}``."
532
+ ),
533
+ )
534
+ nested_blocks.append(block)
535
+ nested_class_names.append(nested_class)
536
+
537
+ # Build the file. Module FQN follows the convention used by sibling
538
+ # generators (entity_model._effective_fqn) — package from nearest ancestor.
539
+ fqn = _effective_fqn_for(template, payload)
540
+ lines: list[str] = [generated_header(template.name, fqn), "from __future__ import annotations\n"]
541
+ for imp in sorted(extra_imports):
542
+ lines.append(imp)
543
+ if extra_imports:
544
+ lines.append("")
545
+ lines.append("from pydantic import BaseModel")
546
+ lines.append("")
547
+ lines.append("")
548
+ # Shared-enum aliases (module scope, deduped, sorted for deterministic output).
549
+ # Referenced by one OR MORE payload fields that extend the same abstract field.enum.
550
+ if enum_aliases:
551
+ for alias in sorted(enum_aliases):
552
+ lines.append(f"{alias} = {enum_aliases[alias]}")
553
+ lines.append("")
554
+ lines.append("")
555
+ # Emit nested classes FIRST. Pydantic v2 with `from __future__ import
556
+ # annotations` evaluates field annotations lazily, but it needs every
557
+ # referenced class to be defined in the module namespace at model-build
558
+ # time — otherwise it raises PydanticUserError("not fully defined") and
559
+ # callers would have to run model_rebuild(). Nested-first avoids that.
560
+ for block in nested_blocks:
561
+ lines.extend(block)
562
+ lines.append("")
563
+ lines.append("")
564
+ lines.extend(primary_block)
565
+ lines.append("")
566
+ lines.append("")
567
+ all_names = [class_name, *nested_class_names]
568
+ quoted = ", ".join(f'"{n}"' for n in all_names)
569
+ lines.append(f"__all__ = [{quoted}]")
570
+ lines.append("")
571
+ return "\n".join(lines)
572
+
573
+
574
+ def _effective_fqn_for(template: MetaTemplate, payload: MetaObject) -> str:
575
+ """``package::name`` for the doc-header. Prefer the template's own package
576
+ chain; fall back to the payload's; finally the bare template name."""
577
+
578
+ def _walk(node: MetaData) -> str | None:
579
+ pkg = node.package
580
+ parent = node.parent
581
+ while pkg is None and parent is not None:
582
+ pkg = parent.package
583
+ parent = parent.parent
584
+ return pkg
585
+
586
+ pkg = _walk(template) or _walk(payload)
587
+ return f"{pkg}{PACKAGE_SEP}{template.name}" if pkg else template.name
588
+
589
+
590
+ # ---------------------------------------------------------------------------
591
+ # Generator wrapper.
592
+ # ---------------------------------------------------------------------------
593
+
594
+
595
+ class PayloadVoGenerator:
596
+ """Generator wrapping ``render_payload_vo``. Emits one file per declared
597
+ ``template.*`` (prompt / output / toolcall) — iterates ALL template
598
+ subtypes uniformly to match the Kotlin reference (no subtype filter)."""
599
+
600
+ name = _GENERATOR_NAME
601
+
602
+ def __init__(self, *, filter: Callable[[MetaObject], bool] | None = None) -> None:
603
+ # The ``filter`` arg matches the cross-generator contract even though
604
+ # this generator iterates templates (not entities).
605
+ self.filter = filter
606
+
607
+ def _emit_payload_class(
608
+ self,
609
+ class_name: str,
610
+ payload_vo: MetaObject,
611
+ root: MetaData,
612
+ nested_emit_queue: list[tuple[MetaObject, str]],
613
+ emitted_nested_fqns: set[str],
614
+ extra_imports: set[str],
615
+ enum_aliases: dict[str, str],
616
+ docstring: str,
617
+ ) -> list[str]:
618
+ """EXTENSION SEAM — the source lines for one Pydantic ``BaseModel`` subclass
619
+ (primary OR a nested collection target). Defaults to the module-level
620
+ :func:`_emit_payload_class`; override to customize the emitted class body
621
+ (e.g. inject ``model_config``, change optionality, add validators)."""
622
+ return _emit_payload_class(
623
+ class_name,
624
+ payload_vo,
625
+ root,
626
+ nested_emit_queue,
627
+ emitted_nested_fqns,
628
+ extra_imports,
629
+ enum_aliases,
630
+ docstring,
631
+ )
632
+
633
+ def _render_module(self, template: MetaTemplate, root: MetaData) -> str | None:
634
+ """EXTENSION SEAM — render the whole payload module for one ``template.*``.
635
+ Defaults to :func:`render_payload_vo` (passing this instance so the
636
+ ``_emit_payload_class`` override is honored). Override to pre/post-process
637
+ the emitted source, or replace the render path entirely."""
638
+ return render_payload_vo(template, root, generator=self)
639
+
640
+ def generate(self, ctx: GenContext) -> list[EmittedFile]:
641
+ root = ctx.loaded_root
642
+ if root is None:
643
+ return []
644
+ files: list[EmittedFile] = []
645
+ templates = sorted(
646
+ (c for c in root.own_children() if c.type == TYPE_TEMPLATE and isinstance(c, MetaTemplate)),
647
+ key=lambda c: c.name,
648
+ )
649
+ # Nested-payload dedupe is per-file (inside render_payload_vo). Each
650
+ # template's emitted module is self-contained — see module docstring.
651
+ for tmpl in templates:
652
+ content = self._render_module(tmpl, root)
653
+ if content is None:
654
+ ctx.warn(
655
+ f"{_GENERATOR_NAME}: skipping template "
656
+ f"'{tmpl.name}' (no resolvable @payloadRef to an object.value)."
657
+ )
658
+ continue
659
+ files.append(
660
+ EmittedFile(
661
+ path=f"{payload_module_name(tmpl.name)}.py",
662
+ content=ruff_format(content),
663
+ )
664
+ )
665
+ return files
666
+
667
+
668
+ def payload_vo_generator(
669
+ *, filter: Callable[[MetaObject], bool] | None = None
670
+ ) -> Generator:
671
+ """Factory mirroring the TS / C# / Kotlin payload-VO generators."""
672
+ return PayloadVoGenerator(filter=filter)