Skip to content

Commit f18a118

Browse files
dmealingclaude
andcommitted
feat(python): yaml-walker tracks line/col positions [FR5b]
Adds a YAML AST walker that retains source positions (1-based line/col) through the desugar pipeline, mirroring the TS reference port (server/typescript/packages/metadata/src/core/yaml-positions.ts + yaml-positions-walker.ts and the position-preserving Rules 1-5 in yaml-desugar.ts). * New `metaobjects.source.yaml_positions` module: - `YamlPositionMap` — dict subclass with a `_yaml_positions` slot; Python's equivalent of the TS Symbol-keyed non-enumerable property carrier. Invisible to `json.dumps` and standard dict iteration, yet `isinstance(d, dict)` still holds. - `parse_yaml_with_positions(text)` — uses `yaml.SafeLoader` + `get_single_node` to compose the YAML AST, then walks it, attaching a position-by-key map to every mapping. Scalars are constructed via `loader.construct_object` so YAML 1.1 coercion matches `yaml.safe_load` (anchors/aliases resolve via the underlying SafeLoader). - `get_position_map` / `get_yaml_position` / `set_position_map` accessors. * `JsonSource` (source/error_source.py) gains an optional `yaml_position` field. The envelope's `format` discriminator stays `"json"` for cross-port fixture stability (matches TS rationale until all four ports ship FR5b). * `yaml_desugar.desugar` now propagates positions through: - Rule 1 (subType fusion): wrapper-key position maps `raw_key -> canonical_key`. - Rule 2 (scalar body lift): synthesized `name` inherits the wrapper key's position. - Rule 4 (`[]` suffix): canonical wrapper key carries the original line. - Rule 5 (sigil-free attrs): bare `filterable` -> `@filterable` rewrite re-keys the body's position map. - Empty bodies produce a positionless `YamlPositionMap` so the wrapper's position is still the only visible one (matches TS recommendation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 91796d8 commit f18a118

4 files changed

Lines changed: 271 additions & 16 deletions

File tree

server/python/src/metaobjects/source/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@
3232
)
3333
from .json_path import JsonPath, JsonPathBuilder
3434
from .semantic_diff import semantic_diff
35+
from .yaml_positions import (
36+
YamlPositionMap,
37+
get_position_map,
38+
get_yaml_position,
39+
parse_yaml_with_positions,
40+
set_position_map,
41+
)
3542

3643
__all__ = [
3744
# ErrorSource hierarchy
@@ -54,4 +61,10 @@
5461
"JsonPath",
5562
# Semantic diff
5663
"semantic_diff",
64+
# FR5b — YAML positions
65+
"YamlPositionMap",
66+
"get_position_map",
67+
"get_yaml_position",
68+
"set_position_map",
69+
"parse_yaml_with_positions",
5770
]

server/python/src/metaobjects/source/error_source.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ class ErrorSource:
3737
format: ClassVar[str] = ""
3838

3939

40+
@dataclass(frozen=True)
41+
class YamlPosition:
42+
"""Optional YAML line/col source position (FR5b). 1-based."""
43+
44+
line: int
45+
col: int
46+
47+
4048
@dataclass(frozen=True)
4149
class JsonSource(ErrorSource):
4250
"""Authoring-time: single JSON file (FR5a).
@@ -46,10 +54,18 @@ class JsonSource(ErrorSource):
4654
invariant guarantees exactly one file — multi-file provenance lives
4755
on :class:`MergedSource` (FR5c). Enforced at construction.
4856
json_path: Canonical JSONPath string for the node within ``files[0]``.
57+
yaml_position: FR5b — optional YAML line/col position. Populated by
58+
the YAML loader when the JSON document was authored as YAML; for
59+
cross-port-safety, the envelope's ``format`` discriminator stays
60+
``"json"`` (so the yaml-conformance fixtures stay byte-stable
61+
until all four ports ship FR5b and the discriminator flips to
62+
``"yaml"``). See TS reference (server/typescript/packages/metadata/
63+
src/source.ts) for the rationale.
4964
"""
5065

5166
files: tuple[str, ...]
5267
json_path: str
68+
yaml_position: Optional[YamlPosition] = None
5369
format: ClassVar[str] = "json"
5470

5571
def __post_init__(self) -> None:
@@ -63,14 +79,6 @@ def __post_init__(self) -> None:
6379
)
6480

6581

66-
@dataclass(frozen=True)
67-
class YamlPosition:
68-
"""Optional YAML line/col source position (FR5b). 1-based."""
69-
70-
line: int
71-
col: int
72-
73-
7482
@dataclass(frozen=True)
7583
class YamlSource(ErrorSource):
7684
"""Authoring-time: single YAML file with optional source-map positions (FR5b)."""
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""FR5b — YAML authoring source-position carrier + walker (per ADR-0009).
2+
3+
Mirrors the TS reference split across:
4+
* server/typescript/packages/metadata/src/core/yaml-positions.ts
5+
(pure types + Symbol carrier + accessors)
6+
* server/typescript/packages/metadata/src/core/yaml-positions-walker.ts
7+
(YAML AST → JS walker that attaches position-by-key maps)
8+
9+
The TS reference splits these for browser-bundle safety (the walker imports
10+
``yaml``, which is Node-only). Python has no such boundary — both layers live
11+
here in one module.
12+
13+
Source-map carrier (the FR5b spec's "open question" §2): TS uses a Symbol-keyed,
14+
non-enumerable property on each mapping object. Python's equivalent is a
15+
``dict`` subclass (:class:`YamlPositionMap`) with a private ``_yaml_positions``
16+
slot. Rationale:
17+
18+
* ``isinstance(d, dict)`` is still ``True`` — code that walks the parsed
19+
structure as plain dicts is unaffected.
20+
* ``json.dumps`` and ``for k in d`` ignore non-key attributes (the
21+
slot is invisible to standard iteration/serialization, matching the
22+
"non-enumerable" property of the TS Symbol-keyed prop).
23+
* No parallel ``id(obj) → positions`` sidecar to keep in sync — the
24+
position rides with the dict it describes.
25+
26+
On desugar-synthesized nodes (Rule 2's scalar-body lift): the synthesized body
27+
``{ name: rawScalar }`` inherits the wrapper key's position from the parent's
28+
position map. On any other synthesis (Rule 4's isArray stamping, Rule 5's
29+
``@``-prefix rewrite), the position survives because the desugar carries the
30+
position map across the rewrite.
31+
"""
32+
from __future__ import annotations
33+
34+
from typing import Any, Optional
35+
36+
import yaml # type: ignore[import-untyped] # PyYAML ships no type stubs
37+
38+
from .error_source import YamlPosition
39+
40+
41+
class YamlPositionMap(dict):
42+
"""A ``dict`` subclass that carries a sidecar position-by-key map.
43+
44+
The carrier of FR5b YAML positions. Instances of this class behave as
45+
plain ``dict`` for every consumer that does not know to look (iteration,
46+
``in``, ``json.dumps``, ``isinstance`` checks), but expose a private
47+
``_yaml_positions`` slot that the loader reads to surface
48+
:class:`YamlPosition` data on ``node.source``.
49+
50+
Key (string) → :class:`YamlPosition` (line, col, 1-based).
51+
"""
52+
53+
__slots__ = ("_yaml_positions",)
54+
55+
def __init__(self, *args: Any, **kwargs: Any) -> None:
56+
super().__init__(*args, **kwargs)
57+
self._yaml_positions: dict[str, YamlPosition] = {}
58+
59+
60+
def get_position_map(obj: Any) -> Optional[dict[str, YamlPosition]]:
61+
"""Read the position-by-key map from a value, if present.
62+
63+
Returns ``None`` for primitives, lists, ``None``, and untagged dicts.
64+
Only :class:`YamlPositionMap` instances carry a map.
65+
"""
66+
if isinstance(obj, YamlPositionMap):
67+
return obj._yaml_positions
68+
return None
69+
70+
71+
def get_yaml_position(obj: Any, key: str) -> Optional[YamlPosition]:
72+
"""Read the position for a specific key on a mapping object.
73+
74+
Returns ``None`` when the mapping is not a :class:`YamlPositionMap` or
75+
the key has no recorded position.
76+
"""
77+
pmap = get_position_map(obj)
78+
if pmap is None:
79+
return None
80+
return pmap.get(key)
81+
82+
83+
def set_position_map(
84+
obj: YamlPositionMap,
85+
positions: dict[str, YamlPosition],
86+
) -> None:
87+
"""Attach (or replace) the position-by-key map on a mapping object.
88+
89+
Mirrors TS ``setPositionMap`` — the map is stored on the dict's private
90+
slot, invisible to ``for k in d`` / ``json.dumps``.
91+
"""
92+
obj._yaml_positions = positions
93+
94+
95+
# ---------------------------------------------------------------------------
96+
# Walker — YAML AST → Python dict tree with positions attached
97+
# ---------------------------------------------------------------------------
98+
99+
100+
def parse_yaml_with_positions(text: str) -> Any:
101+
"""Parse YAML text and return a Python structure with positions attached.
102+
103+
Mirrors the contract of :func:`yaml.safe_load` for the shapes the
104+
metaobjects authoring grammar uses (mappings, sequences, scalars). Aliases
105+
are resolved via the underlying SafeLoader pipeline — i.e. they resolve
106+
as the library normally would.
107+
108+
Every mapping in the resulting structure is a :class:`YamlPositionMap`
109+
with a populated ``_yaml_positions`` sidecar; the (1-based) line/col is
110+
the position of the KEY token in the YAML source.
111+
112+
Raises:
113+
:class:`yaml.YAMLError`: on YAML syntax errors (same as
114+
``yaml.safe_load``).
115+
"""
116+
loader = yaml.SafeLoader(text)
117+
try:
118+
node = loader.get_single_node()
119+
if node is None:
120+
return None
121+
return _yaml_node_to_py(node, loader)
122+
finally:
123+
loader.dispose()
124+
125+
126+
def _yaml_node_to_py(node: Any, loader: Any) -> Any:
127+
"""Walk a yaml AST node into a Python structure.
128+
129+
For each :class:`yaml.MappingNode`, attach a position-by-key map (via
130+
:class:`YamlPositionMap`) onto the resulting dict — the position of each
131+
key is the (line, col) of the KEY token in the YAML source.
132+
133+
Scalar nodes are constructed via the SafeLoader's constructor pipeline,
134+
so YAML 1.1's typed scalars (null, bool, int, float) coerce exactly the
135+
way :func:`yaml.safe_load` produces them. The desugar's D2 coercion
136+
guard fires on those coerced values, matching TS / Java / C# behavior.
137+
"""
138+
if isinstance(node, yaml.ScalarNode):
139+
return loader.construct_object(node, deep=True)
140+
if isinstance(node, yaml.SequenceNode):
141+
return [_yaml_node_to_py(item, loader) for item in node.value]
142+
if isinstance(node, yaml.MappingNode):
143+
out = YamlPositionMap()
144+
positions: dict[str, YamlPosition] = {}
145+
for key_node, value_node in node.value:
146+
# Only string-keyed entries are valid in metaobjects authoring;
147+
# ignore exotic keys (numeric / complex) — they would already
148+
# break the desugar.
149+
if not isinstance(key_node, yaml.ScalarNode):
150+
continue
151+
key_text = str(key_node.value)
152+
value = _yaml_node_to_py(value_node, loader)
153+
out[key_text] = value
154+
# start_mark is 0-indexed; FR5b positions are 1-indexed.
155+
mark = key_node.start_mark
156+
if mark is not None:
157+
positions[key_text] = YamlPosition(
158+
line=mark.line + 1, col=mark.column + 1
159+
)
160+
if positions:
161+
set_position_map(out, positions)
162+
return out
163+
# Tags / unsupported — fall back to None. The metaobjects authoring
164+
# grammar does not use them.
165+
return None
166+
167+
168+
__all__ = [
169+
"YamlPositionMap",
170+
"get_position_map",
171+
"get_yaml_position",
172+
"set_position_map",
173+
"parse_yaml_with_positions",
174+
]

0 commit comments

Comments
 (0)