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.
- metaobjects/__init__.py +75 -0
- metaobjects/agent_context/__init__.py +55 -0
- metaobjects/agent_context/_content/README.md +14 -0
- metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
- metaobjects/agent_context/_content/servers/java.meta.json +5 -0
- metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
- metaobjects/agent_context/_content/servers/python.meta.json +5 -0
- metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
- metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
- metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
- metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
- metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
- metaobjects/agent_context/assemble.py +133 -0
- metaobjects/agent_context/content_root.py +54 -0
- metaobjects/agent_context/scaffold.py +191 -0
- metaobjects/agent_context/types.py +44 -0
- metaobjects/attr_class_map.py +23 -0
- metaobjects/cli.py +696 -0
- metaobjects/codegen/__init__.py +0 -0
- metaobjects/codegen/config.py +11 -0
- metaobjects/codegen/constants.py +13 -0
- metaobjects/codegen/extract_delegate_emitter.py +384 -0
- metaobjects/codegen/extract_schema_emitter.py +139 -0
- metaobjects/codegen/format.py +31 -0
- metaobjects/codegen/fr010_field_mapping.py +220 -0
- metaobjects/codegen/generator.py +62 -0
- metaobjects/codegen/generator_registry.py +163 -0
- metaobjects/codegen/generators/__init__.py +0 -0
- metaobjects/codegen/generators/entity_model.py +263 -0
- metaobjects/codegen/generators/extractor_generator.py +317 -0
- metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
- metaobjects/codegen/generators/m2m_codegen.py +192 -0
- metaobjects/codegen/generators/output_parser_generator.py +272 -0
- metaobjects/codegen/generators/output_prompt_generator.py +192 -0
- metaobjects/codegen/generators/payload_vo_generator.py +672 -0
- metaobjects/codegen/generators/render_helper_generator.py +451 -0
- metaobjects/codegen/generators/router_generator.py +635 -0
- metaobjects/codegen/generators/template_generator.py +70 -0
- metaobjects/codegen/generators/tph_plan.py +120 -0
- metaobjects/codegen/generators/trace_helper_generator.py +336 -0
- metaobjects/codegen/instance_artifacts.py +15 -0
- metaobjects/codegen/output_format_spec_emitter.py +79 -0
- metaobjects/codegen/overwrite_policy.py +27 -0
- metaobjects/codegen/runner.py +110 -0
- metaobjects/codegen/runtime/__init__.py +6 -0
- metaobjects/codegen/runtime/filter_parser.py +193 -0
- metaobjects/codegen/type_map.py +84 -0
- metaobjects/core_types.py +809 -0
- metaobjects/datatype.py +19 -0
- metaobjects/documentation/__init__.py +28 -0
- metaobjects/documentation/doc_constants.py +20 -0
- metaobjects/documentation/doc_provider.py +20 -0
- metaobjects/documentation/doc_schema.py +24 -0
- metaobjects/errors.py +124 -0
- metaobjects/loader/__init__.py +0 -0
- metaobjects/loader/merge.py +287 -0
- metaobjects/loader/meta_data_loader.py +245 -0
- metaobjects/loader/sources/__init__.py +24 -0
- metaobjects/loader/sources/directory_source.py +50 -0
- metaobjects/loader/sources/file_source.py +41 -0
- metaobjects/loader/sources/meta_data_source.py +67 -0
- metaobjects/loader/sources/uri_source.py +56 -0
- metaobjects/loader/validate_discriminator.py +181 -0
- metaobjects/loader/validate_field_readonly.py +146 -0
- metaobjects/loader/validate_source_parameter_ref.py +159 -0
- metaobjects/loader/validate_source_physical_names.py +140 -0
- metaobjects/loader/validation_passes.py +1513 -0
- metaobjects/meta/__init__.py +1 -0
- metaobjects/meta/core/__init__.py +0 -0
- metaobjects/meta/core/attr/__init__.py +0 -0
- metaobjects/meta/core/attr/attr_constants.py +31 -0
- metaobjects/meta/core/attr/meta_attr.py +136 -0
- metaobjects/meta/core/field/__init__.py +0 -0
- metaobjects/meta/core/field/field_constants.py +105 -0
- metaobjects/meta/core/field/meta_field.py +76 -0
- metaobjects/meta/core/identity/__init__.py +0 -0
- metaobjects/meta/core/identity/identity_constants.py +19 -0
- metaobjects/meta/core/identity/meta_identity.py +8 -0
- metaobjects/meta/core/object/__init__.py +0 -0
- metaobjects/meta/core/object/meta_object.py +65 -0
- metaobjects/meta/core/object/meta_object_aware.py +43 -0
- metaobjects/meta/core/object/object_class_registry.py +56 -0
- metaobjects/meta/core/object/object_constants.py +13 -0
- metaobjects/meta/core/object/object_extract.py +400 -0
- metaobjects/meta/core/object/value_object.py +70 -0
- metaobjects/meta/core/relationship/__init__.py +0 -0
- metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
- metaobjects/meta/core/relationship/meta_relationship.py +54 -0
- metaobjects/meta/core/relationship/relationship_constants.py +51 -0
- metaobjects/meta/core/validator/__init__.py +0 -0
- metaobjects/meta/core/validator/validator_constants.py +18 -0
- metaobjects/meta/meta_data.py +206 -0
- metaobjects/meta/meta_root.py +8 -0
- metaobjects/meta/persistence/__init__.py +0 -0
- metaobjects/meta/persistence/db/__init__.py +1 -0
- metaobjects/meta/persistence/db/db_constants.py +41 -0
- metaobjects/meta/persistence/db/db_provider.py +60 -0
- metaobjects/meta/persistence/origin/__init__.py +0 -0
- metaobjects/meta/persistence/origin/meta_origin.py +8 -0
- metaobjects/meta/persistence/origin/origin_constants.py +20 -0
- metaobjects/meta/persistence/source/__init__.py +0 -0
- metaobjects/meta/persistence/source/meta_source.py +137 -0
- metaobjects/meta/persistence/source/source_constants.py +115 -0
- metaobjects/meta/presentation/__init__.py +0 -0
- metaobjects/meta/presentation/layout/__init__.py +0 -0
- metaobjects/meta/presentation/layout/layout_constants.py +13 -0
- metaobjects/meta/presentation/layout/meta_layout.py +8 -0
- metaobjects/meta/presentation/view/__init__.py +0 -0
- metaobjects/meta/presentation/view/meta_view.py +8 -0
- metaobjects/meta/presentation/view/view_constants.py +22 -0
- metaobjects/meta/template/__init__.py +0 -0
- metaobjects/meta/template/meta_template.py +46 -0
- metaobjects/meta/template/template_constants.py +112 -0
- metaobjects/meta/template/template_provider.py +43 -0
- metaobjects/parser.py +380 -0
- metaobjects/parser_yaml.py +82 -0
- metaobjects/provider.py +111 -0
- metaobjects/py.typed +0 -0
- metaobjects/registry.py +210 -0
- metaobjects/registry_manifest.py +223 -0
- metaobjects/render/__init__.py +74 -0
- metaobjects/render/email_document.py +14 -0
- metaobjects/render/escapers.py +109 -0
- metaobjects/render/extract/__init__.py +59 -0
- metaobjects/render/extract/coerce.py +279 -0
- metaobjects/render/extract/extract.py +211 -0
- metaobjects/render/extract/extract_map.py +61 -0
- metaobjects/render/extract/json_forgiving_reader.py +203 -0
- metaobjects/render/extract/locate.py +65 -0
- metaobjects/render/extract/normalize.py +96 -0
- metaobjects/render/extract/strip.py +20 -0
- metaobjects/render/extract/types.py +332 -0
- metaobjects/render/extract/xml_forgiving_reader.py +162 -0
- metaobjects/render/filesystem_provider.py +51 -0
- metaobjects/render/prompt/__init__.py +32 -0
- metaobjects/render/prompt/output_format_renderer.py +340 -0
- metaobjects/render/prompt/output_format_spec.py +28 -0
- metaobjects/render/prompt/prompt_field.py +29 -0
- metaobjects/render/prompt/prompt_overrides.py +29 -0
- metaobjects/render/prompt/prompt_style.py +38 -0
- metaobjects/render/renderer.py +358 -0
- metaobjects/render/verify.py +266 -0
- metaobjects/runtime/__init__.py +39 -0
- metaobjects/runtime/llm_recorder.py +210 -0
- metaobjects/runtime/n2m_resolver.py +155 -0
- metaobjects/runtime/object_manager.py +715 -0
- metaobjects/runtime/tph.py +50 -0
- metaobjects/serializer_json.py +172 -0
- metaobjects/shared/__init__.py +0 -0
- metaobjects/shared/base_types.py +16 -0
- metaobjects/shared/separators.py +4 -0
- metaobjects/shared/structural.py +9 -0
- metaobjects/source/__init__.py +79 -0
- metaobjects/source/error_source.py +266 -0
- metaobjects/source/json_path.py +106 -0
- metaobjects/source/semantic_diff.py +98 -0
- metaobjects/source/yaml_positions.py +174 -0
- metaobjects/super_resolve.py +128 -0
- metaobjects/yaml_desugar.py +481 -0
- metaobjects-0.9.0.dist-info/METADATA +97 -0
- metaobjects-0.9.0.dist-info/RECORD +181 -0
- metaobjects-0.9.0.dist-info/WHEEL +4 -0
- metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|