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,358 @@
1
+ """Mustache-driven render pipeline for ``template.*`` (FR-004).
2
+
3
+ Pipeline: resolve template text (inline or via provider) → pre-expand partials
4
+ by recursive inlining (cycle-guarded, MAX_DEPTH=32) → execute a small
5
+ purpose-built Mustache interpreter that applies the format-keyed
6
+ :mod:`escapers` per variable substitution → fail-closed if the result exceeds
7
+ ``maxChars`` (RAISES, never truncates).
8
+
9
+ Pre-expanding partials before interpretation guarantees deterministic
10
+ cross-port whitespace and lets the engine own cycle detection. The Mustache
11
+ subset implemented (interpolation, raw triple-stache, sections, inverted
12
+ sections, partials, comments) is the corpus's vocabulary — set-delimiter
13
+ directives are intentionally NOT supported (matches the verify side and the
14
+ TS/C#/Java engines).
15
+
16
+ Mirrors ``server/java/render/.../Renderer.java`` and
17
+ ``server/csharp/MetaObjects.Render/Renderer.cs``.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from dataclasses import dataclass, field
23
+ from typing import Any
24
+
25
+ from . import escapers
26
+ from .verify import InMemoryProvider, Provider
27
+
28
+ MAX_DEPTH = 32
29
+
30
+ # {{> name }} — captures the reference between the marker and the closing }}.
31
+ _PARTIAL_PATTERN = re.compile(r"\{\{>\s*([^\s}]+)\s*\}\}")
32
+
33
+
34
+ class RenderError(Exception):
35
+ """Anything that goes wrong during render (unresolved partial, cycle, etc.)."""
36
+
37
+
38
+ @dataclass
39
+ class RenderRequest:
40
+ """One render call.
41
+
42
+ Exactly one of ``template`` / ``ref`` must be set. ``payload`` is the data
43
+ object the Mustache interpreter walks. ``provider`` resolves
44
+ ``{{> partial}}`` references and (when ``ref`` is set) the top-level
45
+ template body. ``format`` defaults to ``"text"`` (no escaping). ``max_chars``
46
+ is an optional fail-closed budget: if the final output exceeds it, render
47
+ RAISES (never truncates).
48
+ """
49
+
50
+ payload: Any
51
+ provider: Provider
52
+ template: str | None = None
53
+ ref: str | None = None
54
+ format: str = escapers.FORMAT_TEXT
55
+ max_chars: int | None = None
56
+
57
+
58
+ def render(req: RenderRequest) -> str:
59
+ _validate(req)
60
+ body = req.template if req.template is not None else _resolve_or_raise(req.provider, req.ref)
61
+ expanded = _pre_expand_partials(body, req.provider, [])
62
+ out = _interpret(expanded, req.payload, req.format)
63
+ # @maxChars is a fail-closed render budget: over-budget output RAISES (never
64
+ # silently truncates). Canonical cross-port behavior — message shape matches
65
+ # TS/C#/Java: "render exceeded maxChars budget: <len> > <cap>".
66
+ if req.max_chars is not None and len(out) > req.max_chars:
67
+ raise RenderError(f"render exceeded maxChars budget: {len(out)} > {req.max_chars}")
68
+ return out
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Validation + partial pre-expansion
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def _validate(req: RenderRequest) -> None:
77
+ if req.provider is None:
78
+ raise RenderError("RenderRequest.provider is required")
79
+ if req.payload is None:
80
+ raise RenderError("RenderRequest.payload is required")
81
+ if req.template is None and req.ref is None:
82
+ raise RenderError("RenderRequest must set either template or ref")
83
+ if req.template is not None and req.ref is not None:
84
+ raise RenderError("RenderRequest must set template OR ref, not both")
85
+
86
+
87
+ def _resolve_or_raise(provider: Provider, ref: str) -> str:
88
+ text = provider.resolve(ref)
89
+ if text is None:
90
+ raise RenderError(f"unresolved ref: {ref}")
91
+ return text
92
+
93
+
94
+ def _pre_expand_partials(text: str, provider: Provider, stack: list[str]) -> str:
95
+ if len(stack) >= MAX_DEPTH:
96
+ raise RenderError(
97
+ f"partial depth exceeded {MAX_DEPTH} (chain: {' -> '.join(stack)})"
98
+ )
99
+
100
+ def replace(match: re.Match[str]) -> str:
101
+ ref = match.group(1)
102
+ if ref in stack:
103
+ raise RenderError(f"partial cycle: {' -> '.join(stack)} -> {ref}")
104
+ body = provider.resolve(ref)
105
+ if body is None:
106
+ raise RenderError(f"unresolved partial: {ref}")
107
+ stack.append(ref)
108
+ try:
109
+ return _pre_expand_partials(body, provider, stack)
110
+ finally:
111
+ stack.pop()
112
+
113
+ return _PARTIAL_PATTERN.sub(replace, text)
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Mustache interpreter — purpose-built, minimal vocabulary
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class _Text:
123
+ value: str
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class _Interp:
128
+ path: str
129
+ raw: bool # True for {{{x}}} or {{&x}}
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class _Section:
134
+ path: str
135
+ inverted: bool
136
+ children: tuple["_Node", ...] = field(default=())
137
+
138
+
139
+ _Node = _Text | _Interp | _Section
140
+
141
+
142
+ def _tokenize(text: str) -> tuple[_Node, ...]:
143
+ tokens, _ = _parse(text, 0)
144
+ return tokens
145
+
146
+
147
+ # Sigils whose tags are "block-level" (eligible for the standalone-line whitespace
148
+ # strip rule per the Mustache spec). Interpolation tags are NOT block-level —
149
+ # `{{x}}` alone on a line keeps its surrounding newline.
150
+ _BLOCK_SIGILS = frozenset(("#", "^", "/", "!", ">"))
151
+
152
+
153
+ def _is_standalone_line(text: str, open_idx: int, after_idx: int) -> tuple[bool, int]:
154
+ """Return ``(is_standalone, eat_to)`` for a tag opening at *open_idx* and
155
+ closing at *after_idx*.
156
+
157
+ A tag is "standalone" iff the text from the previous newline (or BOF) to
158
+ *open_idx* is whitespace AND the text from *after_idx* to the next newline
159
+ (or EOF) is whitespace. When standalone, *eat_to* is one past the trailing
160
+ newline — so callers can advance past it — and the prior Text token's
161
+ leading-line whitespace should be trimmed.
162
+ """
163
+ # Look back to start of line.
164
+ line_start = text.rfind("\n", 0, open_idx) + 1 # rfind == -1 → 0
165
+ before = text[line_start:open_idx]
166
+ if before and not before.isspace():
167
+ return False, after_idx
168
+ # Look forward to end of line.
169
+ eat_to = after_idx
170
+ while eat_to < len(text) and text[eat_to] != "\n":
171
+ if not text[eat_to].isspace():
172
+ return False, after_idx
173
+ eat_to += 1
174
+ if eat_to < len(text):
175
+ eat_to += 1 # include the newline itself
176
+ return True, eat_to
177
+
178
+
179
+ def _strip_trailing_line_ws(t: _Text) -> _Text:
180
+ """Drop the trailing-line whitespace (post the last newline) from *t* — the
181
+ "leading-line ws" half of the standalone-tag whitespace rule."""
182
+ v = t.value
183
+ nl = v.rfind("\n")
184
+ if nl < 0:
185
+ # Whole text is whitespace — if so, drop it; otherwise keep as-is.
186
+ return _Text("") if v.isspace() else t
187
+ return _Text(v[: nl + 1])
188
+
189
+
190
+ def _parse(text: str, pos: int) -> tuple[tuple[_Node, ...], int]:
191
+ out: list[_Node] = []
192
+ while pos < len(text):
193
+ open_idx = text.find("{{", pos)
194
+ if open_idx < 0:
195
+ if pos < len(text):
196
+ out.append(_Text(text[pos:]))
197
+ return tuple(out), len(text)
198
+ # Defer creating the preceding Text token until we know whether
199
+ # this tag is standalone (in which case we trim its trailing-line ws).
200
+ leading_text = text[pos:open_idx]
201
+ triple = open_idx + 2 < len(text) and text[open_idx + 2] == "{"
202
+ close_delim = "}}}" if triple else "}}"
203
+ content_start = open_idx + (3 if triple else 2)
204
+ close_idx = text.find(close_delim, content_start)
205
+ if close_idx < 0:
206
+ # Malformed — treat the rest as plain text.
207
+ out.append(_Text(text[pos:]))
208
+ return tuple(out), len(text)
209
+ content = text[content_start:close_idx].strip()
210
+ after_idx = close_idx + len(close_delim)
211
+
212
+ sigil = content[0] if (not triple and content) else ""
213
+ is_block = sigil in _BLOCK_SIGILS
214
+ if is_block:
215
+ standalone, eat_to = _is_standalone_line(text, open_idx, after_idx)
216
+ if standalone:
217
+ # Trim the leading-line ws off the preceding text segment.
218
+ leading_text_trimmed = _strip_trailing_line_ws(_Text(leading_text)).value
219
+ if leading_text_trimmed:
220
+ out.append(_Text(leading_text_trimmed))
221
+ pos = eat_to
222
+ else:
223
+ if leading_text:
224
+ out.append(_Text(leading_text))
225
+ pos = after_idx
226
+ else:
227
+ if leading_text:
228
+ out.append(_Text(leading_text))
229
+ pos = after_idx
230
+
231
+ if triple:
232
+ out.append(_Interp(content, raw=True))
233
+ continue
234
+ if not content:
235
+ continue
236
+
237
+ name = content[1:].strip() if len(content) > 1 else ""
238
+
239
+ if sigil == "!":
240
+ continue # comment
241
+ if sigil in ("#", "^"):
242
+ children, pos = _parse(text, pos)
243
+ out.append(_Section(name, inverted=(sigil == "^"), children=children))
244
+ elif sigil == "/":
245
+ return tuple(out), pos # close — caller resumes
246
+ elif sigil == "&":
247
+ out.append(_Interp(name, raw=True))
248
+ elif sigil == ">":
249
+ # Partial — should have been pre-expanded. If we still see one, it
250
+ # means a partial body referenced an unknown one. Render as empty
251
+ # so the rendered output stays well-formed; the pre-expand step is
252
+ # responsible for raising on unresolved partials.
253
+ continue
254
+ else:
255
+ out.append(_Interp(content, raw=False))
256
+ return tuple(out), pos
257
+
258
+
259
+ def _interpret(text: str, payload: Any, format_: str) -> str:
260
+ tokens = _tokenize(text)
261
+ parts: list[str] = []
262
+ _emit(tokens, [payload], parts, format_)
263
+ return "".join(parts)
264
+
265
+
266
+ def _emit(
267
+ tokens: tuple[_Node, ...],
268
+ stack: list[Any],
269
+ out: list[str],
270
+ format_: str,
271
+ ) -> None:
272
+ for tok in tokens:
273
+ if isinstance(tok, _Text):
274
+ out.append(tok.value)
275
+ elif isinstance(tok, _Interp):
276
+ value = _resolve(tok.path, stack)
277
+ if value is None or value is False:
278
+ continue
279
+ text = _stringify(value)
280
+ out.append(text if tok.raw else escapers.escape(format_, text))
281
+ elif isinstance(tok, _Section):
282
+ value = _resolve(tok.path, stack)
283
+ truthy = _truthy(value)
284
+ if tok.inverted:
285
+ if not truthy:
286
+ _emit(tok.children, stack, out, format_)
287
+ continue
288
+ if not truthy:
289
+ continue
290
+ if isinstance(value, list):
291
+ for item in value:
292
+ stack.append(item)
293
+ try:
294
+ _emit(tok.children, stack, out, format_)
295
+ finally:
296
+ stack.pop()
297
+ elif isinstance(value, dict):
298
+ stack.append(value)
299
+ try:
300
+ _emit(tok.children, stack, out, format_)
301
+ finally:
302
+ stack.pop()
303
+ else:
304
+ # truthy scalar — render body once in current context
305
+ _emit(tok.children, stack, out, format_)
306
+
307
+
308
+ def _resolve(path: str, stack: list[Any]) -> Any:
309
+ """Mustache lookup: first segment walks the context stack innermost→outermost;
310
+ remaining segments descend into the resolved value. Implicit iterator
311
+ ``{{.}}`` returns the top of the stack."""
312
+ if path == ".":
313
+ return stack[-1] if stack else None
314
+ segs = path.split(".")
315
+ current = _lookup(stack, segs[0])
316
+ for seg in segs[1:]:
317
+ if current is None:
318
+ return None
319
+ current = _lookup([current], seg)
320
+ return current
321
+
322
+
323
+ def _lookup(stack: list[Any], name: str) -> Any:
324
+ for ctx in reversed(stack):
325
+ if isinstance(ctx, dict) and name in ctx:
326
+ return ctx[name]
327
+ return None
328
+
329
+
330
+ def _truthy(value: Any) -> bool:
331
+ if value is None or value is False:
332
+ return False
333
+ if isinstance(value, list) and not value:
334
+ return False
335
+ if isinstance(value, str) and not value:
336
+ return False
337
+ return True
338
+
339
+
340
+ def _stringify(value: Any) -> str:
341
+ if isinstance(value, bool):
342
+ return "true" if value else "false"
343
+ if isinstance(value, (int, float)):
344
+ # Avoid Python's "1.0" suffix for whole-number floats.
345
+ if isinstance(value, float) and value.is_integer():
346
+ return str(int(value))
347
+ return str(value)
348
+ return str(value)
349
+
350
+
351
+ __all__ = [
352
+ "InMemoryProvider",
353
+ "MAX_DEPTH",
354
+ "Provider",
355
+ "RenderError",
356
+ "RenderRequest",
357
+ "render",
358
+ ]
@@ -0,0 +1,266 @@
1
+ """Template-side drift check (FR-004 Plan #3) — the Python port of ``verify``.
2
+
3
+ ``verify`` parses a Mustache template's TEXT and cross-checks every variable,
4
+ section, and partial against the declared field tree of the template's payload
5
+ view-object — catching "a renamed field silently broke a prompt". Zero-dependency
6
+ by design: it takes a plain :class:`PayloadField` tree (no loader import) and a
7
+ small purpose-built Mustache *tag* tokenizer (it needs tag structure, not
8
+ rendering/whitespace).
9
+
10
+ Ported from ``typescript/packages/render/src/verify.ts`` and
11
+ ``csharp/MetaObjects.Render/Verify.cs``; the three share the
12
+ ``fixtures/verify-conformance/`` corpus that proves they agree.
13
+
14
+ The tokenizer models only plain interpolation, sections, inverted sections, and
15
+ partials (matching the C# port). It deliberately does NOT model Mustache
16
+ set-delimiter directives (``{{=<% %>=}}``); the TS engine parses with the real
17
+ ``mustache`` library, so a fixture using set-delimiters would diverge across
18
+ ports. The corpus uses only plain tags — keep it that way.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from typing import Protocol
25
+
26
+ #: A ``{{var}}`` references a field the (contextual) payload does not declare.
27
+ ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD"
28
+ #: A ``{{> ref}}`` partial does not resolve in the provider.
29
+ ERR_PARTIAL_UNRESOLVED = "ERR_PARTIAL_UNRESOLVED"
30
+ #: A declared @requiredSlots slot is never referenced by the template (warning).
31
+ ERR_REQUIRED_SLOT_UNUSED = "ERR_REQUIRED_SLOT_UNUSED"
32
+ #: A declared @requiredTags output tag is absent from the template text.
33
+ ERR_OUTPUT_TAG_MISSING = "ERR_OUTPUT_TAG_MISSING"
34
+
35
+ _MAX_DEPTH = 32
36
+ #: An opening tag is ``<tag`` immediately followed by ``>`` or XML whitespace, so
37
+ #: attributes are allowed (``<answer foo="1">``) but a longer name is not over-matched
38
+ #: (``<answers>`` does not satisfy ``answer``).
39
+ _TAG_OPEN_DELIMS = frozenset((">", " ", "\t", "\n", "\r"))
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class PayloadField:
44
+ """A plain field-tree node mirroring an ``object.value`` view-object's walk.
45
+
46
+ ``fields`` present → a context-pushing field (object / array-of-object);
47
+ ``fields is None`` → a scalar (string/number/boolean/scalar-array).
48
+ """
49
+
50
+ name: str
51
+ fields: list[PayloadField] | None = None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class VerifyError:
56
+ """A single drift finding: a code + the offending var path / partial ref / slot."""
57
+
58
+ code: str
59
+ path: str
60
+
61
+
62
+ class Provider(Protocol):
63
+ """Resolves a ``{{> group/source}}`` partial ref to its body text, or ``None``."""
64
+
65
+ def resolve(self, ref: str) -> str | None: ...
66
+
67
+
68
+ class InMemoryProvider:
69
+ """A :class:`Provider` backed by a dict of ref → body text."""
70
+
71
+ def __init__(self, partials: dict[str, str] | None = None) -> None:
72
+ self._partials = dict(partials or {})
73
+
74
+ def resolve(self, ref: str) -> str | None:
75
+ return self._partials.get(ref)
76
+
77
+
78
+ # --- Mustache tag token model (rendering-agnostic; only tag structure) ---
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class _Var:
83
+ value: str
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class _Section:
88
+ value: str
89
+ inverted: bool
90
+ children: list[_Token]
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class _Partial:
95
+ value: str
96
+
97
+
98
+ _Token = _Var | _Section | _Partial
99
+
100
+
101
+ def _tokenize(text: str) -> list[_Token]:
102
+ tokens, _ = _parse_tokens(text, 0)
103
+ return tokens
104
+
105
+
106
+ def _parse_tokens(text: str, pos: int) -> tuple[list[_Token], int]:
107
+ """Parse tokens until EOF or a matching ``{{/close}}`` (which is consumed)."""
108
+ tokens: list[_Token] = []
109
+ while pos < len(text):
110
+ open_idx = text.find("{{", pos)
111
+ if open_idx < 0:
112
+ break # remainder is plain text
113
+ triple = open_idx + 2 < len(text) and text[open_idx + 2] == "{"
114
+ close_delim = "}}}" if triple else "}}"
115
+ content_start = open_idx + (3 if triple else 2)
116
+ close_idx = text.find(close_delim, content_start)
117
+ if close_idx < 0:
118
+ break # malformed; stop
119
+ content = text[content_start:close_idx].strip()
120
+ pos = close_idx + len(close_delim)
121
+
122
+ if triple:
123
+ tokens.append(_Var(content))
124
+ continue
125
+ if not content:
126
+ continue
127
+
128
+ sigil = content[0]
129
+ name = content[1:].strip() if len(content) > 1 else ""
130
+ if sigil == "!":
131
+ continue # comment
132
+ if sigil in ("#", "^"):
133
+ children, pos = _parse_tokens(text, pos)
134
+ tokens.append(_Section(name, sigil == "^", children))
135
+ elif sigil == "/":
136
+ return tokens, pos # close (matched or mismatched) ends this level
137
+ elif sigil == ">":
138
+ tokens.append(_Partial(name))
139
+ elif sigil == "&":
140
+ tokens.append(_Var(name))
141
+ else:
142
+ tokens.append(_Var(content)) # {{x}}, {{x.y}}, {{.}}
143
+ return tokens, pos
144
+
145
+
146
+ def _find(fields: list[PayloadField], name: str) -> PayloadField | None:
147
+ return next((f for f in fields if f.name == name), None)
148
+
149
+
150
+ def _resolve(stack: list[list[PayloadField]], path: str) -> PayloadField | None:
151
+ """Resolve a (possibly dotted) path the way Mustache does: the FIRST segment is
152
+ looked up through the context stack (innermost → outermost); each remaining
153
+ segment descends into the resolved field's ``fields``.
154
+ """
155
+ segs = path.split(".")
156
+ current: PayloadField | None = None
157
+ for level in reversed(stack):
158
+ hit = _find(level, segs[0])
159
+ if hit is not None:
160
+ current = hit
161
+ break
162
+ for seg in segs[1:]:
163
+ if current is None:
164
+ break
165
+ current = _find(current.fields, seg) if current.fields is not None else None
166
+ return current
167
+
168
+
169
+ def _has_open_tag(text: str, tag: str) -> bool:
170
+ """Whether ``text`` contains an opening form of ``tag`` (see ``_TAG_OPEN_DELIMS``)."""
171
+ needle = f"<{tag}"
172
+ i = text.find(needle)
173
+ while i != -1:
174
+ after = i + len(needle)
175
+ if after < len(text) and text[after] in _TAG_OPEN_DELIMS:
176
+ return True
177
+ i = text.find(needle, i + 1)
178
+ return False
179
+
180
+
181
+ def _has_close_tag(text: str, tag: str) -> bool:
182
+ """A closing tag is the exact literal ``</tag>``. A self-closing ``<tag/>`` has no
183
+ such form, so it never satisfies a required tag — these wrap content a parser reads.
184
+ """
185
+ return f"</{tag}>" in text
186
+
187
+
188
+ def verify(
189
+ template_text: str,
190
+ fields: list[PayloadField],
191
+ *,
192
+ provider: Provider | None = None,
193
+ required_slots: list[str] | None = None,
194
+ required_tags: list[str] | None = None,
195
+ ) -> list[VerifyError]:
196
+ """Walk a Mustache template's tokens against a payload field tree, returning a
197
+ list of drift findings. Context-sensitive: a section ``{{#posts}}…{{/posts}}``
198
+ over a container field checks its body against that field's element type.
199
+ """
200
+ errors: list[VerifyError] = []
201
+ root = fields
202
+ referenced_at_root: set[str] = set()
203
+ # The static text the output-tag check scans: the body plus every
204
+ # provider-resolved partial body, collected during the single walk below
205
+ # (no second resolution pass).
206
+ static_texts: list[str] = [template_text]
207
+
208
+ def walk(
209
+ tokens: list[_Token], stack: list[list[PayloadField]], seen: list[str]
210
+ ) -> None:
211
+ at_root = len(stack) == 1 and stack[0] is root
212
+ for tok in tokens:
213
+ if isinstance(tok, _Var):
214
+ if tok.value == ".":
215
+ continue # implicit iterator — always valid
216
+ if at_root:
217
+ referenced_at_root.add(tok.value.split(".")[0])
218
+ if _resolve(stack, tok.value) is None:
219
+ errors.append(VerifyError(ERR_VAR_NOT_ON_PAYLOAD, tok.value))
220
+ elif isinstance(tok, _Section):
221
+ if tok.value == ".":
222
+ walk(tok.children, stack, seen)
223
+ continue
224
+ if at_root:
225
+ referenced_at_root.add(tok.value.split(".")[0])
226
+ field = _resolve(stack, tok.value)
227
+ if field is None:
228
+ # Unresolved section head is itself drift; skip the body (its
229
+ # context is unknowable, walking it would cascade false errors).
230
+ errors.append(VerifyError(ERR_VAR_NOT_ON_PAYLOAD, tok.value))
231
+ continue
232
+ # `#` over a container pushes its element fields; `^` (and `#` over a
233
+ # scalar, used as a conditional) keep the current context.
234
+ if not tok.inverted and field.fields is not None:
235
+ walk(tok.children, [*stack, field.fields], seen)
236
+ else:
237
+ walk(tok.children, stack, seen)
238
+ else: # _Partial
239
+ if provider is None:
240
+ continue # can't resolve without a provider
241
+ if tok.value in seen or len(seen) >= _MAX_DEPTH:
242
+ continue # cycle/depth guard
243
+ body = provider.resolve(tok.value)
244
+ if body is None:
245
+ errors.append(VerifyError(ERR_PARTIAL_UNRESOLVED, tok.value))
246
+ continue
247
+ static_texts.append(body)
248
+ walk(_tokenize(body), stack, [*seen, tok.value])
249
+
250
+ walk(_tokenize(template_text), [root], [])
251
+
252
+ for slot in required_slots or []:
253
+ if slot not in referenced_at_root:
254
+ errors.append(VerifyError(ERR_REQUIRED_SLOT_UNUSED, slot))
255
+
256
+ if required_tags:
257
+ # Scan body + resolved partials as one joined string: the open and close
258
+ # forms are located independently, so a tag may legitimately straddle the
259
+ # boundary. The "\n" separator only blocks a spurious tag spliced together
260
+ # from two fragments (a real tag is always contiguous within one body).
261
+ haystack = "\n".join(static_texts)
262
+ for tag in required_tags:
263
+ if not _has_open_tag(haystack, tag) or not _has_close_tag(haystack, tag):
264
+ errors.append(VerifyError(ERR_OUTPUT_TAG_MISSING, tag))
265
+
266
+ return errors
@@ -0,0 +1,39 @@
1
+ """Minimal runtime persistence layer — ObjectManager + Postgres driver.
2
+
3
+ Cross-port shape mirrors TS runtime-ts and Java ObjectManagerDB: method-based
4
+ query API (`find_by_id` / `find_many` / `count`) that translates a Filter dict
5
+ into parameterized SQL via a pluggable driver. The corpus query scenarios
6
+ exercise eq/ne/gt/gte/lt/lte/in/like/isNull operators + the top-level `and:`
7
+ combinator + asc/desc sort + limit/offset.
8
+ """
9
+ from .object_manager import Filter, ObjectManager, PostgresDriver
10
+ from .llm_recorder import (
11
+ STATUS_ERROR,
12
+ STATUS_OK,
13
+ LlmCallInput,
14
+ LlmCallRecorder,
15
+ LlmCallRow,
16
+ NullLlmCallRecorder,
17
+ ObjectManagerLlmCallRecorder,
18
+ build_llm_call_row,
19
+ persist_llm_call_row,
20
+ record_llm_call,
21
+ truncate_row,
22
+ )
23
+
24
+ __all__ = [
25
+ "Filter",
26
+ "ObjectManager",
27
+ "PostgresDriver",
28
+ "STATUS_OK",
29
+ "STATUS_ERROR",
30
+ "LlmCallInput",
31
+ "LlmCallRecorder",
32
+ "LlmCallRow",
33
+ "NullLlmCallRecorder",
34
+ "ObjectManagerLlmCallRecorder",
35
+ "build_llm_call_row",
36
+ "persist_llm_call_row",
37
+ "record_llm_call",
38
+ "truncate_row",
39
+ ]