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,451 @@
1
+ """Render-helper codegen — one ``<template_name_snake>_render_helper.py`` per
2
+ ``template.output`` declaration (render-helper phase 2).
3
+
4
+ For each ``template.output`` this emits a typed ``render_<name>(payload, provider)``
5
+ function that WRAPS the existing :func:`metaobjects.render.renderer.render` engine,
6
+ and enforces the mustache↔payload-VO drift check (the existing
7
+ :func:`metaobjects.render.verify.verify`) at BUILD time.
8
+
9
+ Two shapes keyed off ``@kind``:
10
+
11
+ * ``document`` (default) → renders ``@textRef`` in ``@format`` → one ``str``.
12
+ * ``email`` → renders ``@subjectRef`` (text) + ``@htmlBodyRef`` (html)
13
+ (+ optional ``@textBodyRef``, text) → an
14
+ :class:`metaobjects.render.email_document.EmailDocument`.
15
+
16
+ THE HEADLINE is the BUILD-TIME drift gate. BEFORE emitting, every referenced
17
+ mustache (document: ``@textRef``; email: subject + html (+ optional text)) is
18
+ resolved through a :class:`~metaobjects.render.filesystem_provider.FilesystemProvider`
19
+ rooted at the ``template_root`` ctor arg and run through ``verify``. If a referenced
20
+ text is unresolvable OR carries any NON-warning error (anything other than
21
+ ``ERR_REQUIRED_SLOT_UNUSED`` — a ``{{field}}`` not on the payload VO produces
22
+ ``ERR_VAR_NOT_ON_PAYLOAD``), the generator RAISES (fails codegen), naming the
23
+ template, the ref, the error code, and the offending field. This is what makes the
24
+ build fail when a mustache references a field the payload VO doesn't declare.
25
+
26
+ Reuse, not reimplementation: ``render`` (the emitted runtime call), ``verify`` (the
27
+ build-time gate), ``FilesystemProvider`` (build-time ref resolution), and
28
+ ``EmailDocument`` (email return type). The payload field tree is walked from the VO
29
+ the same way the other generators walk it — but a nested ``field.object``'s
30
+ ``@objectRef`` is resolved by BARE short-name (cross-port render-helper consensus:
31
+ TS ``findObject`` / Java ``resolveNestedObjectRef`` / C# ``ResolveNestedObjectRef``),
32
+ only recursing into ``object.value`` targets, cycle-guarded.
33
+
34
+ Python divergence vs TS / Java / C#: Python's ``RenderRequest`` has NO ``verify``
35
+ field — the Python render engine does not run a runtime drift pass — so the emitted
36
+ helper does NOT pass a runtime ``verify`` field-tree. The BUILD-TIME gate that runs
37
+ here is the only, and a complete, drift guarantee: codegen cannot succeed unless
38
+ every referenced mustache verifies clean against the payload VO. The field-tree walk
39
+ is still performed (it is what ``verify`` checks against); it is simply not baked into
40
+ the emitted call. The emitted helper shape is otherwise identical to the other ports.
41
+
42
+ Drift message style matches the other ports exactly:
43
+ ``render-helper drift: template "<N>" ref "<r>" — <CODE>: {{<f>}} not on payload VO``
44
+ """
45
+ from __future__ import annotations
46
+
47
+ from collections.abc import Callable
48
+
49
+ from metaobjects.codegen.constants import generated_header
50
+ from metaobjects.codegen.format import ruff_format
51
+ from metaobjects.codegen.generator import EmittedFile, GenContext, Generator
52
+ from metaobjects.meta.core.field import field_constants as fc
53
+ from metaobjects.meta.core.field.meta_field import MetaField
54
+ from metaobjects.meta.core.object.meta_object import MetaObject
55
+ from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_VALUE
56
+ from metaobjects.meta.meta_data import MetaData
57
+ from metaobjects.meta.template import template_constants as tc
58
+ from metaobjects.render.filesystem_provider import FilesystemProvider
59
+ from metaobjects.render.verify import (
60
+ ERR_REQUIRED_SLOT_UNUSED,
61
+ PayloadField,
62
+ verify,
63
+ )
64
+ from metaobjects.shared.base_types import TYPE_FIELD, TYPE_OBJECT, TYPE_TEMPLATE
65
+ from metaobjects.shared.separators import PACKAGE_SEP
66
+
67
+ _GENERATOR_NAME = "render-helper-generator"
68
+
69
+
70
+ def _snake_case(name: str) -> str:
71
+ """``WelcomePage`` → ``welcome_page``. Trivial PascalCase → snake_case (no acronym
72
+ handling; matches the convention used by the sibling generators)."""
73
+ out: list[str] = []
74
+ for i, ch in enumerate(name):
75
+ if ch.isupper() and i > 0:
76
+ out.append("_")
77
+ out.append(ch.lower())
78
+ return "".join(out)
79
+
80
+
81
+ def _py_str(s: str) -> str:
82
+ """A double-quoted Python string literal with the minimal escaping the refs/formats
83
+ need (refs are ``group/source`` slugs; formats are closed-enum words)."""
84
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Payload field-tree walk — nested @objectRef resolved by BARE short-name
89
+ # (cross-port render-helper consensus). Object-ref fields recurse into their
90
+ # target object.value (SUBTYPE_VALUE only); a `seen` set guards reference cycles.
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ def _resolve_nested_object_ref(root: MetaData, reference: str) -> MetaObject | None:
95
+ """Resolve a ``field.object``'s ``@objectRef`` to its target ``object.value`` by BARE
96
+ short-name — mirroring the C# ``ResolveNestedObjectRef`` / TS ``findObject`` / Java
97
+ ``resolveNestedObjectRef``. If the ref carries a package, only the segment after the
98
+ last ``::`` is compared, against each ``object.value``'s own short name."""
99
+ if not reference:
100
+ return None
101
+ ref_short = reference.rsplit(PACKAGE_SEP, 1)[-1]
102
+ for child in root.own_children():
103
+ if child.type != TYPE_OBJECT or not isinstance(child, MetaObject):
104
+ continue
105
+ if child.sub_type != OBJECT_SUBTYPE_VALUE:
106
+ continue
107
+ if child.name.rsplit(PACKAGE_SEP, 1)[-1] == ref_short:
108
+ return child
109
+ return None
110
+
111
+
112
+ def _derive_payload_field_tree(
113
+ root: MetaData, vo: MetaObject, seen: frozenset[str]
114
+ ) -> list[PayloadField]:
115
+ """Walk *vo*'s fields into a :class:`PayloadField` tree. A ``field.object`` with a
116
+ resolvable ``@objectRef`` to an ``object.value`` becomes a context-pushing node whose
117
+ children are the target VO's tree (recursed, cycle-guarded). Every other field is a
118
+ leaf."""
119
+ if vo is None or vo.name in seen:
120
+ return []
121
+ next_seen = seen | {vo.name}
122
+ fields: list[PayloadField] = []
123
+ for f in vo.children():
124
+ if f.type != TYPE_FIELD or not isinstance(f, MetaField):
125
+ continue
126
+ if f.sub_type == fc.FIELD_SUBTYPE_OBJECT:
127
+ ref = f.attr(fc.FIELD_ATTR_OBJECT_REF)
128
+ if isinstance(ref, str) and ref:
129
+ target = _resolve_nested_object_ref(root, ref)
130
+ if target is not None and target.sub_type == OBJECT_SUBTYPE_VALUE:
131
+ children = _derive_payload_field_tree(root, target, next_seen)
132
+ fields.append(PayloadField(f.name, children))
133
+ continue
134
+ fields.append(PayloadField(f.name))
135
+ return fields
136
+
137
+
138
+ def _field_tree_literal(fields: list[PayloadField]) -> str:
139
+ """Emit a ``list[PayloadField]`` as a deterministic Python literal:
140
+ ``[PayloadField("name"), PayloadField("nested", [PayloadField("x")])]``."""
141
+ parts: list[str] = []
142
+ for f in fields:
143
+ if f.fields is not None:
144
+ parts.append(f"PayloadField({_py_str(f.name)}, {_field_tree_literal(f.fields)})")
145
+ else:
146
+ parts.append(f"PayloadField({_py_str(f.name)})")
147
+ return "[" + ", ".join(parts) + "]"
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Resolution + emission.
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ def _resolve_payload_vo(root: MetaData, payload_ref: str) -> MetaObject | None:
156
+ """``@payloadRef`` must resolve to an ``object.value`` (same contract as the
157
+ parser/prompt generators). Bare short-name match on a value-object child."""
158
+ for child in root.own_children():
159
+ if child.type != TYPE_OBJECT or not isinstance(child, MetaObject):
160
+ continue
161
+ if child.sub_type == OBJECT_SUBTYPE_VALUE and child.name == payload_ref:
162
+ return child
163
+ return None
164
+
165
+
166
+ def _max_chars_of(tmpl: MetaData) -> int | None:
167
+ """Resolve ``@maxChars`` (own attr) as an int, or ``None`` when absent/non-numeric."""
168
+ v = tmpl.attr(tc.TEMPLATE_ATTR_MAX_CHARS)
169
+ if isinstance(v, bool):
170
+ return None
171
+ if isinstance(v, int):
172
+ return v
173
+ if isinstance(v, str):
174
+ try:
175
+ return int(v)
176
+ except ValueError:
177
+ return None
178
+ return None
179
+
180
+
181
+ class RenderHelperGenerator:
182
+ """Generator wrapping the per-``template.output`` render-helper emit. Construct with
183
+ the on-disk template root the build-time drift gate resolves each referenced
184
+ mustache against — required, without it the gate cannot run.
185
+
186
+ EXTENSION SEAM (open-for-extension). Adopters subclass this and override one of
187
+ the protected emit hooks to customize the emitted helper without forking:
188
+
189
+ * ``_emit_document(header, template_name, snake, payload_ref, tmpl, fields)`` —
190
+ the ``document``-kind helper (single ``str`` return).
191
+ * ``_emit_email(header, template_name, snake, payload_ref, tmpl, fields)`` — the
192
+ ``email``-kind helper (``EmailDocument`` return).
193
+ * ``_emit_helper(tmpl, vo, payload_ref, root)`` — the per-template dispatch
194
+ (runs the build-time drift gate, then routes to document/email).
195
+
196
+ The build-time drift gate (``_gate_ref``) is also overridable, but tightening it
197
+ is the safer direction than loosening it. The factory ``render_helper_generator()``
198
+ returns a default instance, so the default suite stays byte-identical."""
199
+
200
+ name = _GENERATOR_NAME
201
+
202
+ def __init__(
203
+ self,
204
+ template_root: str,
205
+ *,
206
+ filter: Callable[[MetaObject], bool] | None = None,
207
+ ) -> None:
208
+ if not template_root:
209
+ raise ValueError(
210
+ "RenderHelperGenerator requires a template_root (the on-disk template "
211
+ "dir) for the build-time drift gate"
212
+ )
213
+ # The ``filter`` arg matches the cross-generator contract even though this
214
+ # generator iterates templates (not entities).
215
+ self.filter = filter
216
+ self._provider = FilesystemProvider(template_root)
217
+
218
+ def generate(self, ctx: GenContext) -> list[EmittedFile]:
219
+ root = ctx.loaded_root
220
+ if root is None:
221
+ return []
222
+ outputs = sorted(
223
+ (
224
+ c
225
+ for c in root.own_children()
226
+ if c.type == TYPE_TEMPLATE
227
+ and c.sub_type == tc.TEMPLATE_SUBTYPE_OUTPUT
228
+ ),
229
+ key=lambda c: c.name,
230
+ )
231
+ files: list[EmittedFile] = []
232
+ for tmpl in outputs:
233
+ payload_ref = tmpl.attr(tc.TEMPLATE_ATTR_PAYLOAD_REF)
234
+ if not isinstance(payload_ref, str) or not payload_ref:
235
+ ctx.warn(
236
+ f"{_GENERATOR_NAME}: template.output '{tmpl.name}' missing "
237
+ "@payloadRef — skipped."
238
+ )
239
+ continue
240
+ vo = _resolve_payload_vo(root, payload_ref)
241
+ if vo is None:
242
+ ctx.warn(
243
+ f"{_GENERATOR_NAME}: template.output '{tmpl.name}' @payloadRef "
244
+ f"'{payload_ref}' does not resolve to an object.value — skipped."
245
+ )
246
+ continue
247
+ # _emit_helper runs the build-time drift gate first and RAISES (fails
248
+ # codegen) on a mustache↔VO drift — intentionally NOT caught.
249
+ content = self._emit_helper(tmpl, vo, payload_ref, root)
250
+ files.append(
251
+ EmittedFile(
252
+ path=f"{_snake_case(tmpl.name)}_render_helper.py",
253
+ content=ruff_format(content),
254
+ )
255
+ )
256
+ return files
257
+
258
+ # -- build-time drift gate -------------------------------------------------
259
+
260
+ def _gate_ref(
261
+ self, template_name: str, reference: str, fields: list[PayloadField]
262
+ ) -> None:
263
+ """Run the build-time drift gate for one referenced mustache. RAISES (fails
264
+ codegen) when the ref is unresolvable OR ``verify`` reports a non-warning error.
265
+ Warnings (``ERR_REQUIRED_SLOT_UNUSED``) are tolerated. Matches the cross-port
266
+ message style exactly."""
267
+ text = self._provider.resolve(reference)
268
+ if text is None:
269
+ raise ValueError(
270
+ f'render-helper drift: template "{template_name}" ref "{reference}" '
271
+ "— unresolved (provider returned no text)"
272
+ )
273
+ for e in verify(text, fields, provider=self._provider):
274
+ if e.code == ERR_REQUIRED_SLOT_UNUSED:
275
+ continue # warning
276
+ raise ValueError(
277
+ f'render-helper drift: template "{template_name}" ref "{reference}" '
278
+ f"— {e.code}: {{{{{e.path}}}}} not on payload VO"
279
+ )
280
+
281
+ # -- emission --------------------------------------------------------------
282
+
283
+ def _emit_helper(
284
+ self,
285
+ tmpl: MetaData,
286
+ vo: MetaObject,
287
+ payload_ref: str,
288
+ root: MetaData,
289
+ ) -> str:
290
+ template_name = tmpl.name
291
+ snake = _snake_case(template_name)
292
+ fields = _derive_payload_field_tree(root, vo, frozenset())
293
+ kind = tmpl.attr(tc.TEMPLATE_ATTR_KIND)
294
+ kind = (kind if isinstance(kind, str) else tc.TEMPLATE_KIND_DEFAULT).lower()
295
+
296
+ fqn = (
297
+ f"{vo.package}{PACKAGE_SEP}{template_name}"
298
+ if getattr(vo, "package", None)
299
+ else template_name
300
+ )
301
+ header = generated_header(template_name, fqn)
302
+
303
+ if kind == tc.TEMPLATE_KIND_EMAIL:
304
+ return self._emit_email(header, template_name, snake, payload_ref, tmpl, fields)
305
+ return self._emit_document(header, template_name, snake, payload_ref, tmpl, fields)
306
+
307
+ def _emit_document(
308
+ self,
309
+ header: str,
310
+ template_name: str,
311
+ snake: str,
312
+ payload_ref: str,
313
+ tmpl: MetaData,
314
+ fields: list[PayloadField],
315
+ ) -> str:
316
+ text_ref = tmpl.attr(tc.TEMPLATE_ATTR_TEXT_REF)
317
+ if not isinstance(text_ref, str) or not text_ref:
318
+ raise ValueError(
319
+ f'template.output "{template_name}" (document) missing @textRef'
320
+ )
321
+ fmt = tmpl.attr(tc.TEMPLATE_ATTR_FORMAT)
322
+ fmt = fmt if isinstance(fmt, str) and fmt else tc.TEMPLATE_FORMAT_DEFAULT
323
+ max_chars = _max_chars_of(tmpl)
324
+
325
+ # BUILD-TIME drift gate (THE headline) — raises on drift.
326
+ self._gate_ref(template_name, text_ref, fields)
327
+
328
+ request_lines = [
329
+ " RenderRequest(",
330
+ " payload=payload,",
331
+ " provider=provider,",
332
+ f" ref={_py_str(text_ref)},",
333
+ f" format={_py_str(fmt)},",
334
+ ]
335
+ if max_chars is not None:
336
+ request_lines.append(f" max_chars={max_chars},")
337
+ request_lines.append(" )")
338
+
339
+ lines = [
340
+ header,
341
+ "from __future__ import annotations",
342
+ "",
343
+ "from metaobjects.render.renderer import render, RenderRequest",
344
+ "",
345
+ "",
346
+ f"def render_{snake}(payload, provider) -> str:",
347
+ f' """Render the ``{template_name}`` document from a typed '
348
+ f"``{payload_ref}`` payload.",
349
+ "",
350
+ " Wraps the render engine; the mustache↔payload-VO drift check ran at",
351
+ ' BUILD time (codegen fails on drift)."""',
352
+ " return render(",
353
+ *request_lines,
354
+ " )",
355
+ "",
356
+ "",
357
+ f'__all__ = ["render_{snake}"]',
358
+ "",
359
+ ]
360
+ return "\n".join(lines)
361
+
362
+ def _emit_email(
363
+ self,
364
+ header: str,
365
+ template_name: str,
366
+ snake: str,
367
+ payload_ref: str,
368
+ tmpl: MetaData,
369
+ fields: list[PayloadField],
370
+ ) -> str:
371
+ subject_ref = tmpl.attr(tc.TEMPLATE_ATTR_SUBJECT_REF)
372
+ html_body_ref = tmpl.attr(tc.TEMPLATE_ATTR_HTML_BODY_REF)
373
+ text_body_ref = tmpl.attr(tc.TEMPLATE_ATTR_TEXT_BODY_REF)
374
+ if not isinstance(subject_ref, str) or not subject_ref:
375
+ raise ValueError(
376
+ f'template.output "{template_name}" (email) missing @subjectRef'
377
+ )
378
+ if not isinstance(html_body_ref, str) or not html_body_ref:
379
+ raise ValueError(
380
+ f'template.output "{template_name}" (email) missing @htmlBodyRef'
381
+ )
382
+ has_text = isinstance(text_body_ref, str) and bool(text_body_ref)
383
+
384
+ # BUILD-TIME drift gate — every email part-ref is resolved + verified.
385
+ self._gate_ref(template_name, subject_ref, fields)
386
+ self._gate_ref(template_name, html_body_ref, fields)
387
+ if has_text:
388
+ self._gate_ref(template_name, text_body_ref, fields)
389
+
390
+ text_body_expr = (
391
+ "render(\n"
392
+ " RenderRequest(\n"
393
+ " payload=payload,\n"
394
+ " provider=provider,\n"
395
+ f" ref={_py_str(text_body_ref)},\n"
396
+ ' format="text",\n'
397
+ " )\n"
398
+ " )"
399
+ if has_text
400
+ else "None"
401
+ )
402
+
403
+ lines = [
404
+ header,
405
+ "from __future__ import annotations",
406
+ "",
407
+ "from metaobjects.render.email_document import EmailDocument",
408
+ "from metaobjects.render.renderer import render, RenderRequest",
409
+ "",
410
+ "",
411
+ f"def render_{snake}(payload, provider) -> EmailDocument:",
412
+ f' """Render the ``{template_name}`` email (subject + html body'
413
+ f'{" + text body" if has_text else ""}) from a typed ``{payload_ref}`` payload.',
414
+ "",
415
+ " Wraps the render engine; the mustache↔payload-VO drift check ran at",
416
+ ' BUILD time (codegen fails on drift)."""',
417
+ " return EmailDocument(",
418
+ " subject=render(",
419
+ " RenderRequest(",
420
+ " payload=payload,",
421
+ " provider=provider,",
422
+ f" ref={_py_str(subject_ref)},",
423
+ ' format="text",',
424
+ " )",
425
+ " ),",
426
+ " html_body=render(",
427
+ " RenderRequest(",
428
+ " payload=payload,",
429
+ " provider=provider,",
430
+ f" ref={_py_str(html_body_ref)},",
431
+ ' format="html",',
432
+ " )",
433
+ " ),",
434
+ f" text_body={text_body_expr},",
435
+ " )",
436
+ "",
437
+ "",
438
+ f'__all__ = ["render_{snake}"]',
439
+ "",
440
+ ]
441
+ return "\n".join(lines)
442
+
443
+
444
+ def render_helper_generator(
445
+ *,
446
+ template_root: str,
447
+ filter: Callable[[MetaObject], bool] | None = None,
448
+ ) -> Generator:
449
+ """Factory mirroring the TS ``renderHelper()`` / C# ``RenderHelperGenerator`` /
450
+ Java ``SpringRenderHelperGenerator`` constructors."""
451
+ return RenderHelperGenerator(template_root, filter=filter)