tenncell 0.7.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 (67) hide show
  1. nnc/__init__.py +3 -0
  2. nnc/__main__.py +6 -0
  3. nnc/_version.py +1 -0
  4. nnc/cli.py +152 -0
  5. nnc/cli_transform.py +268 -0
  6. nnc/inputs/__init__.py +1 -0
  7. nnc/inputs/yaml/__init__.py +1 -0
  8. nnc/inputs/yaml/errors.py +61 -0
  9. nnc/inputs/yaml/lexical.py +60 -0
  10. nnc/inputs/yaml/locations.py +89 -0
  11. nnc/inputs/yaml/lowering/__init__.py +24 -0
  12. nnc/inputs/yaml/lowering/common.py +85 -0
  13. nnc/inputs/yaml/lowering/fsm.py +203 -0
  14. nnc/inputs/yaml/lowering/rules.py +60 -0
  15. nnc/inputs/yaml/module_config.py +57 -0
  16. nnc/inputs/yaml/parsing.py +41 -0
  17. nnc/inputs/yaml/raw_model.py +52 -0
  18. nnc/inputs/yaml/resolution.py +51 -0
  19. nnc/inputs/yaml/sections.py +30 -0
  20. nnc/inputs/yaml/yaml_loader.py +217 -0
  21. nnc/model/__init__.py +16 -0
  22. nnc/model/cell.py +25 -0
  23. nnc/model/rule.py +45 -0
  24. nnc/model/system.py +456 -0
  25. nnc/parser/__init__.py +13 -0
  26. nnc/parser/ast/__init__.py +61 -0
  27. nnc/parser/ast/boolean_expression.py +152 -0
  28. nnc/parser/ast/expression.py +268 -0
  29. nnc/parser/ast/value/__init__.py +12 -0
  30. nnc/parser/ast/value/array_value.py +64 -0
  31. nnc/parser/ast/value/base_value.py +34 -0
  32. nnc/parser/ast/value/float_value.py +35 -0
  33. nnc/parser/ast/value/math_functions.py +156 -0
  34. nnc/parser/ast/value/numerical_value.py +313 -0
  35. nnc/parser/ast/variable.py +16 -0
  36. nnc/parser/parser.py +357 -0
  37. nnc/transformers/__init__.py +22 -0
  38. nnc/transformers/base_transformer.py +50 -0
  39. nnc/transformers/python/__init__.py +11 -0
  40. nnc/transformers/python/core_emitter.py +320 -0
  41. nnc/transformers/python/csv_script_emitter.py +188 -0
  42. nnc/transformers/python/emission_context.py +17 -0
  43. nnc/transformers/python/expression_emitter.py +192 -0
  44. nnc/transformers/python_transformer.py +182 -0
  45. nnc/transformers/verilog/__init__.py +33 -0
  46. nnc/transformers/verilog/generation/__init__.py +1 -0
  47. nnc/transformers/verilog/generation/context.py +33 -0
  48. nnc/transformers/verilog/generation/conversions.py +343 -0
  49. nnc/transformers/verilog/generation/encodings.py +76 -0
  50. nnc/transformers/verilog/generation/expressions.py +517 -0
  51. nnc/transformers/verilog/generation/literals.py +159 -0
  52. nnc/transformers/verilog/generation/references.py +505 -0
  53. nnc/transformers/verilog/generation/state.py +165 -0
  54. nnc/transformers/verilog/generation/structure.py +486 -0
  55. nnc/transformers/verilog/hardware_config.py +209 -0
  56. nnc/transformers/verilog/verilog_yaml_section_parser.py +395 -0
  57. nnc/transformers/verilog_transformer.py +253 -0
  58. nnc/transformers/webots/__init__.py +22 -0
  59. nnc/transformers/webots/emission_context.py +14 -0
  60. nnc/transformers/webots/webots_config.py +50 -0
  61. nnc/transformers/webots/webots_yaml_section_parser.py +147 -0
  62. nnc/transformers/webots_transformer.py +217 -0
  63. tenncell-0.7.0.dist-info/METADATA +502 -0
  64. tenncell-0.7.0.dist-info/RECORD +67 -0
  65. tenncell-0.7.0.dist-info/WHEEL +4 -0
  66. tenncell-0.7.0.dist-info/entry_points.txt +6 -0
  67. tenncell-0.7.0.dist-info/licenses/LICENSE +21 -0
nnc/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .model import Cell, NncSystem, Rule
2
+
3
+ __all__ = ["Cell", "Rule", "NncSystem"]
nnc/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
nnc/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = '0.7.0'
nnc/cli.py ADDED
@@ -0,0 +1,152 @@
1
+ import argparse
2
+ import csv
3
+ import sys
4
+ from contextlib import ExitStack
5
+ from pathlib import Path
6
+
7
+ from nnc.model.system import NncSystem
8
+
9
+ from nnc._version import __version__
10
+
11
+
12
+ def main() -> int:
13
+ """Run the TENNCell simulator CLI and return a process-style exit code."""
14
+ try:
15
+ return _run()
16
+ except Exception as e:
17
+ print(f"Error: {e}", file=sys.stderr)
18
+ return 1
19
+
20
+
21
+ def _run() -> int:
22
+ """Parse arguments, run the simulator, and return ``0`` on success."""
23
+ parser = argparse.ArgumentParser(
24
+ description=f"TENNCell simulator v{__version__}", prog="nnc-sim"
25
+ )
26
+ parser.add_argument(
27
+ "system_file",
28
+ type=Path,
29
+ help="The file containing the description of the TENNCell system",
30
+ )
31
+ parser.add_argument(
32
+ "input",
33
+ type=Path,
34
+ nargs="?",
35
+ default=None,
36
+ help="Input (CSV) file giving stimulus for each step (default: stdin)",
37
+ )
38
+ parser.add_argument(
39
+ "output",
40
+ type=Path,
41
+ nargs="?",
42
+ default=None,
43
+ help="Output (CSV) file giving the results of each step (default: stdout)",
44
+ )
45
+ parser.add_argument(
46
+ "-c",
47
+ "--compute_mode",
48
+ action="store_true",
49
+ help="Run in continuous compute mode (default: False)",
50
+ )
51
+ parser.add_argument(
52
+ "-s", "--steps", type=int, default=1, help="Number of steps to run (default: 1)"
53
+ )
54
+ parser.add_argument(
55
+ "--csv",
56
+ action="store_true",
57
+ help="Output in CSV format when in continuous compute mode",
58
+ )
59
+ args = parser.parse_args()
60
+
61
+ with ExitStack() as stack:
62
+ input_handle = (
63
+ sys.stdin
64
+ if args.input is None
65
+ else stack.enter_context(args.input.open("r", encoding="utf-8", newline=""))
66
+ )
67
+ output_handle = (
68
+ sys.stdout
69
+ if args.output is None
70
+ else stack.enter_context(
71
+ args.output.open("w", encoding="utf-8", newline="")
72
+ )
73
+ )
74
+ nnc = NncSystem.from_yaml(str(args.system_file))
75
+
76
+ if args.compute_mode:
77
+ if len(nnc.input_variables) > 0:
78
+ raise ValueError(
79
+ "Cannot run in continuous compute mode with input variables"
80
+ )
81
+ if args.csv:
82
+ # CSV output mode
83
+ result = {
84
+ name: str(variable.value)
85
+ for name, variable in nnc.output_variables.items()
86
+ }
87
+ fieldnames = ["step"] + list(result.keys())
88
+ writer = csv.DictWriter(
89
+ output_handle, fieldnames=fieldnames, lineterminator="\n"
90
+ )
91
+ writer.writeheader()
92
+ # Write initial state (step 0) with only output variables
93
+ row = {"step": 0}
94
+ row.update(result)
95
+ writer.writerow(row)
96
+ for i in range(args.steps):
97
+ nnc.step()
98
+ result = {
99
+ name: str(variable.value)
100
+ for name, variable in nnc.output_variables.items()
101
+ }
102
+ row = {"step": i + 1}
103
+ row.update(result)
104
+ writer.writerow(row)
105
+ else:
106
+ print(
107
+ f"Running in continuous compute mode for {args.steps} steps",
108
+ file=output_handle,
109
+ )
110
+ result = {
111
+ name: str(variable.value)
112
+ for name, variable in nnc.output_variables.items()
113
+ }
114
+ print(f"Step: 0: {result}", file=output_handle)
115
+ for i in range(args.steps):
116
+ nnc.step()
117
+ result = {
118
+ name: str(variable.value)
119
+ for name, variable in nnc.output_variables.items()
120
+ }
121
+ print(f"Step: {i + 1}: {result}", file=output_handle)
122
+ else: # IO mode
123
+ reader = csv.DictReader(input_handle)
124
+ writer = None
125
+
126
+ for csv_row in reader:
127
+ inputs = {}
128
+ for input_var_name, input_var_value in csv_row.items():
129
+ if input_var_name not in nnc.input_variables.keys():
130
+ raise ValueError(f"Unknown input variable {input_var_name}")
131
+ inputs[input_var_name] = float(input_var_value)
132
+
133
+ nnc.step(inputs)
134
+
135
+ result = {
136
+ name: variable.value
137
+ for name, variable in nnc.output_variables.items()
138
+ }
139
+
140
+ if writer is None:
141
+ writer = csv.DictWriter(
142
+ output_handle,
143
+ fieldnames=result.keys(),
144
+ lineterminator="\n",
145
+ )
146
+ writer.writeheader()
147
+ writer.writerow(result)
148
+ return 0
149
+
150
+
151
+ if __name__ == "__main__":
152
+ sys.exit(main())
nnc/cli_transform.py ADDED
@@ -0,0 +1,268 @@
1
+ """CLI tool for transforming TENNCell systems to various output formats."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Dict, Type
8
+
9
+ from nnc.inputs.yaml.sections import YamlSectionContext, parse_registered_sections
10
+ from nnc.inputs.yaml.locations import YamlLocationIndex
11
+ from nnc.model.system import NncSystem
12
+ from nnc.transformers import (
13
+ BaseTransformer,
14
+ PythonTransformer,
15
+ VerilogTransformer,
16
+ WebotsTransformer,
17
+ )
18
+ from nnc.transformers.webots.webots_yaml_section_parser import parse_webots_section
19
+ from nnc.transformers.verilog.verilog_yaml_section_parser import parse_verilog_section
20
+ from nnc._version import __version__
21
+
22
+
23
+ # Registry of available transformers
24
+ TRANSFORMERS: Dict[str, Type[BaseTransformer]] = {
25
+ "python": PythonTransformer,
26
+ "verilog": VerilogTransformer,
27
+ "webots": WebotsTransformer,
28
+ }
29
+
30
+
31
+ def get_transformer(transform_type: str) -> BaseTransformer:
32
+ """Get a transformer instance by type name.
33
+
34
+ Args:
35
+ transform_type: The type of transformer to create
36
+
37
+ Returns:
38
+ Transformer instance
39
+
40
+ Raises:
41
+ ValueError: If transform_type is not supported
42
+ """
43
+ if transform_type not in TRANSFORMERS:
44
+ available = ", ".join(TRANSFORMERS.keys())
45
+ raise ValueError(
46
+ f"Unsupported transformation type '{transform_type}'. Available types: {available}"
47
+ )
48
+
49
+ return TRANSFORMERS[transform_type]()
50
+
51
+
52
+ def main():
53
+ """Main entry point for the ``nnc-gen`` CLI."""
54
+ parser = argparse.ArgumentParser(
55
+ description=f"TENNCell Transformer v{__version__} - Convert TENNCell systems to various output formats",
56
+ prog="nnc-gen",
57
+ )
58
+
59
+ parser.add_argument(
60
+ "system_files", nargs="+", help="TENNCell system files (.yaml) to transform"
61
+ )
62
+
63
+ parser.add_argument(
64
+ "-t",
65
+ "--type",
66
+ dest="transform_type",
67
+ required=True,
68
+ choices=list(TRANSFORMERS.keys()),
69
+ help="Transformation type",
70
+ )
71
+
72
+ parser.add_argument(
73
+ "-o",
74
+ "--output-dir",
75
+ type=Path,
76
+ default=Path("."),
77
+ help="Output directory for transformed files (default: current directory)",
78
+ )
79
+
80
+ parser.add_argument(
81
+ "--output-suffix",
82
+ default="",
83
+ help="Suffix to add to output filenames (before extension)",
84
+ )
85
+
86
+ parser.add_argument(
87
+ "-v", "--verbose", action="store_true", help="Enable verbose output"
88
+ )
89
+ parser.add_argument(
90
+ "--import-path",
91
+ action="append",
92
+ default=[],
93
+ help="Additional directory to search for imported TENNCell files and external headers",
94
+ )
95
+ parser.add_argument(
96
+ "--import-paths",
97
+ default="",
98
+ help="Path-separated list of additional import directories",
99
+ )
100
+
101
+ args = parser.parse_args()
102
+
103
+ # Ensure output directory exists
104
+ args.output_dir.mkdir(parents=True, exist_ok=True)
105
+
106
+ # Get transformer
107
+ try:
108
+ transformer = get_transformer(args.transform_type)
109
+ except ValueError as e:
110
+ print(f"Error: {e}", file=sys.stderr)
111
+ sys.exit(1)
112
+
113
+ extra_import_paths = list(args.import_path)
114
+ if args.import_paths:
115
+ extra_import_paths.extend(
116
+ [path for path in args.import_paths.split(os.pathsep) if path]
117
+ )
118
+
119
+ # Process each file
120
+ had_error = False
121
+ for nnc_file_path in args.system_files:
122
+ nnc_file = Path(nnc_file_path)
123
+
124
+ if not nnc_file.exists():
125
+ print(f"Error: File '{nnc_file}' not found", file=sys.stderr)
126
+ had_error = True
127
+ continue
128
+
129
+ if args.verbose:
130
+ print(f"Processing: {nnc_file}")
131
+
132
+ try:
133
+ # Load TENNCell system
134
+ raw_data_cache = {}
135
+ nnc_system = NncSystem.from_yaml(
136
+ str(nnc_file),
137
+ import_paths=extra_import_paths,
138
+ _raw_data_cache=raw_data_cache,
139
+ )
140
+
141
+ # Verilog emits one file per module in the import closure.
142
+ # Python and Webots emit one composed file for the root system only.
143
+ if args.transform_type == "verilog":
144
+ systems_to_emit = _collect_import_closure(nnc_system)
145
+ assert isinstance(transformer, VerilogTransformer)
146
+ transformer.set_verilog_configs(
147
+ _parse_verilog_configs(
148
+ systems_to_emit, raw_data_cache, extra_import_paths
149
+ )
150
+ )
151
+ elif args.transform_type == "webots":
152
+ assert isinstance(transformer, WebotsTransformer)
153
+ transformer.set_webots_configs(
154
+ _parse_webots_configs([nnc_system], raw_data_cache)
155
+ )
156
+ systems_to_emit = [nnc_system]
157
+ else:
158
+ systems_to_emit = [nnc_system]
159
+ for system in systems_to_emit:
160
+ transformed_code = transformer.transform(system)
161
+ source_path = (
162
+ getattr(system, "__dict__", {}).get("source_path") or nnc_file
163
+ )
164
+ base_name = source_path.stem
165
+ if args.output_suffix:
166
+ base_name += args.output_suffix
167
+
168
+ output_file = args.output_dir / (
169
+ base_name + transformer.get_file_extension()
170
+ )
171
+
172
+ with open(output_file, "w", encoding="utf-8") as f:
173
+ f.write(transformed_code)
174
+
175
+ if args.verbose:
176
+ print(f" -> {output_file}")
177
+
178
+ except Exception as e:
179
+ print(f"Error processing '{nnc_file}': {e}", file=sys.stderr)
180
+ had_error = True
181
+ if args.verbose:
182
+ import traceback
183
+
184
+ traceback.print_exc()
185
+ continue
186
+
187
+ if args.verbose:
188
+ print("Transformation complete.")
189
+ if had_error:
190
+ return 1
191
+ return 0
192
+
193
+
194
+ def _collect_import_closure(root: NncSystem) -> list[NncSystem]:
195
+ seen: set[str] = set()
196
+ ordered: list[NncSystem] = []
197
+
198
+ def visit(system: NncSystem):
199
+ source_key = str(getattr(system, "source_path", None) or id(system))
200
+ if source_key in seen:
201
+ return
202
+ seen.add(source_key)
203
+ imports = getattr(system, "__dict__", {}).get("imports", [])
204
+ for item in imports or []:
205
+ if getattr(item, "system", None) is not None:
206
+ visit(item.system)
207
+ ordered.append(system)
208
+
209
+ visit(root)
210
+ return ordered
211
+
212
+
213
+ def _parse_verilog_configs(
214
+ systems: list[NncSystem],
215
+ raw_data_cache: dict[Path, dict],
216
+ import_paths: list[str],
217
+ ):
218
+ configs = {}
219
+ header_cache: dict[Path, object] = {}
220
+ import_paths_resolved = [Path(path).resolve() for path in import_paths]
221
+ for system in systems:
222
+ if system.source_path is None:
223
+ raise ValueError("Verilog export requires systems loaded from YAML files")
224
+ data = raw_data_cache[system.source_path]
225
+ context = YamlSectionContext(
226
+ source_path=system.source_path,
227
+ import_paths=import_paths_resolved,
228
+ header_cache=header_cache,
229
+ raw_data=data,
230
+ locations=system.source_locations
231
+ or YamlLocationIndex(system.source_path, {}),
232
+ )
233
+ parsed = parse_registered_sections(
234
+ data, context, {"verilog": parse_verilog_section}
235
+ )
236
+ configs[system.source_path] = parsed["verilog"]
237
+ return configs
238
+
239
+
240
+ def _parse_webots_configs(
241
+ systems: list[NncSystem],
242
+ raw_data_cache: dict[Path, dict],
243
+ ):
244
+ configs = {}
245
+ for system in systems:
246
+ if system.source_path is None:
247
+ raise ValueError("Webots export requires systems loaded from YAML files")
248
+ data = raw_data_cache[system.source_path]
249
+ context = YamlSectionContext(
250
+ source_path=system.source_path,
251
+ import_paths=[],
252
+ header_cache={},
253
+ raw_data=data,
254
+ locations=system.source_locations
255
+ or YamlLocationIndex(system.source_path, {}),
256
+ )
257
+ parsed = parse_registered_sections(
258
+ data, context, {"webots": parse_webots_section}
259
+ )
260
+ configs[system.source_path] = parsed["webots"]
261
+ return configs
262
+
263
+
264
+ if __name__ == "__main__":
265
+ try:
266
+ sys.exit(main())
267
+ except Exception as e:
268
+ print(f"Error: {e}")
nnc/inputs/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """TENNCell input format loaders."""
@@ -0,0 +1 @@
1
+ """TENNCell YAML input loader package."""
@@ -0,0 +1,61 @@
1
+ """YAML loader error helpers for TENNCell."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .locations import format_yaml_location
8
+
9
+
10
+ class YamlLocatedError(ValueError):
11
+ """ValueError carrying YAML source location metadata."""
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ source_path: Path | None = None,
17
+ line: int | None = None,
18
+ ):
19
+ super().__init__(message)
20
+ self.message = message
21
+ self.source_path = source_path
22
+ self.line = line
23
+
24
+ def attach_location(
25
+ self, source_path: Path | None = None, line: int | None = None
26
+ ) -> "YamlLocatedError":
27
+ """Update the stored location and return the error itself."""
28
+ if source_path is not None:
29
+ self.source_path = source_path
30
+ if line is not None:
31
+ self.line = line
32
+ return self
33
+
34
+ @property
35
+ def location(self) -> str | None:
36
+ """Return the formatted location prefix, if available."""
37
+ if self.source_path is None:
38
+ return None
39
+ return format_yaml_location(self.source_path, self.line)
40
+
41
+ def __str__(self) -> str:
42
+ if self.location is None:
43
+ return self.message
44
+ return f"{self.location}: {self.message}"
45
+
46
+
47
+ def as_yaml_located_error(
48
+ error: Exception,
49
+ source_path: Path | None = None,
50
+ line: int | None = None,
51
+ ) -> YamlLocatedError:
52
+ """Convert or annotate an exception with YAML location metadata."""
53
+ if isinstance(error, YamlLocatedError):
54
+ if error.source_path is None:
55
+ error.attach_location(source_path, line)
56
+ elif error.line is None and line is not None:
57
+ error.attach_location(line=line)
58
+ return error
59
+ located_error = YamlLocatedError(str(error), source_path, line)
60
+ located_error.__cause__ = error
61
+ return located_error
@@ -0,0 +1,60 @@
1
+ """Lexical preprocessing helpers for TENNCell YAML loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable
7
+
8
+
9
+ def _split_comment(line: str) -> tuple[str, str]:
10
+ """Split a YAML line into content and an inline comment."""
11
+ line = line.rstrip("\r\n")
12
+ in_single = False
13
+ in_double = False
14
+ for index, char in enumerate(line):
15
+ if char == "'" and not in_double:
16
+ in_single = not in_single
17
+ elif char == '"' and not in_single:
18
+ in_double = not in_double
19
+ elif char == "#" and not in_single and not in_double:
20
+ if index == 0 or line[index - 1].isspace():
21
+ return line[:index], line[index:]
22
+ return line, ""
23
+
24
+
25
+ def _quote_bang_scalar(content: str) -> str:
26
+ """Quote an unquoted scalar that starts with a bang."""
27
+ if not content:
28
+ return content
29
+ prefix = ""
30
+ value = content
31
+ if ":" in content:
32
+ prefix, value = content.split(":", 1)
33
+ if not value.startswith((" ", "\t")):
34
+ return content
35
+ prefix = f"{prefix}: "
36
+ value = value.lstrip()
37
+ elif content.lstrip().startswith("- "):
38
+ leading = len(content) - len(content.lstrip())
39
+ prefix = content[:leading] + "- "
40
+ value = content[leading + 2 :].lstrip()
41
+ else:
42
+ return content
43
+
44
+ if not value.startswith("!"):
45
+ return content
46
+ return f"{prefix}{json.dumps(value)}"
47
+
48
+
49
+ def sanitize_bang_prefixed_scalars(lines: Iterable[str]) -> str:
50
+ """Quote plain YAML scalars that start with ``!``.
51
+
52
+ PyYAML interprets bare ``!name`` values as tags. TENNCell expressions use
53
+ ``!`` as a boolean operator, so plain scalars that start with ``!`` must be
54
+ converted to quoted strings before the YAML parser sees them.
55
+ """
56
+ sanitized_lines: list[str] = []
57
+ for line in lines:
58
+ content, comment = _split_comment(line)
59
+ sanitized_lines.append(_quote_bang_scalar(content) + comment)
60
+ return "\n".join(sanitized_lines)
@@ -0,0 +1,89 @@
1
+ """Source-location helpers for TENNCell YAML documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+ from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
11
+
12
+ from .lexical import sanitize_bang_prefixed_scalars
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class YamlLocationIndex:
17
+ """Path-to-line index for one YAML source file."""
18
+
19
+ source_path: Path
20
+ paths: dict[tuple[object, ...], int]
21
+
22
+ def line_for(self, *path: object) -> int | None:
23
+ """Return the 1-based line number for a YAML path, if known."""
24
+ return self.paths.get(path)
25
+
26
+ def first_line_under(self, *prefix: object) -> int | None:
27
+ """Return the first known line number under a YAML path prefix."""
28
+ for path, line in self.paths.items():
29
+ if len(path) >= len(prefix) and path[: len(prefix)] == prefix:
30
+ return line
31
+ return None
32
+
33
+ def error(self, message: str, *path: object) -> YamlLocatedError:
34
+ """Build a located YAML error for this file and path."""
35
+ from .errors import YamlLocatedError
36
+
37
+ return YamlLocatedError(message, self.source_path, self.line_for(*path))
38
+
39
+
40
+ def _node_key(node: Node) -> object:
41
+ """Convert a YAML node used as a mapping key into a hashable path element."""
42
+ if isinstance(node, ScalarNode):
43
+ return node.value
44
+ return yaml.safe_load(yaml.serialize(node))
45
+
46
+
47
+ def collect_yaml_locations(node: Node | None) -> dict[tuple[object, ...], int]:
48
+ """Collect 1-based line numbers for YAML values reachable from a node tree."""
49
+ locations: dict[tuple[object, ...], int] = {}
50
+
51
+ def visit(current: Node, path: tuple[object, ...]) -> None:
52
+ if isinstance(current, MappingNode):
53
+ for key_node, value_node in current.value:
54
+ key = _node_key(key_node)
55
+ child_path = path + (key,)
56
+ locations[child_path] = value_node.start_mark.line + 1
57
+ if isinstance(value_node, (MappingNode, SequenceNode)):
58
+ visit(value_node, child_path)
59
+ return
60
+
61
+ if isinstance(current, SequenceNode):
62
+ for index, item in enumerate(current.value):
63
+ child_path = path + (index,)
64
+ locations[child_path] = item.start_mark.line + 1
65
+ if isinstance(item, (MappingNode, SequenceNode)):
66
+ visit(item, child_path)
67
+ return
68
+
69
+ if node is not None:
70
+ visit(node, ())
71
+ return locations
72
+
73
+
74
+ def load_yaml_data_and_locations(
75
+ source_path: Path,
76
+ ) -> tuple[dict[str, Any], YamlLocationIndex]:
77
+ """Load YAML content and collect path-to-line metadata for the document."""
78
+ with source_path.open("r", encoding="utf-8") as file:
79
+ sanitized = sanitize_bang_prefixed_scalars(file)
80
+ node = yaml.compose(sanitized)
81
+ data = yaml.safe_load(sanitized) or {}
82
+ return data, YamlLocationIndex(source_path, collect_yaml_locations(node))
83
+
84
+
85
+ def format_yaml_location(source_path: Path, line: int | None) -> str:
86
+ """Format a source location prefix for a YAML error message."""
87
+ if line is None:
88
+ return str(source_path)
89
+ return f"{source_path}:{line}"
@@ -0,0 +1,24 @@
1
+ """TENNCell YAML lowering helpers."""
2
+
3
+ from .common import (
4
+ combine_guards,
5
+ guard_negation,
6
+ make_transition_rule,
7
+ normalize_rule_items,
8
+ parse_structured_rule,
9
+ with_guard,
10
+ )
11
+ from .fsm import build_state_constants, lower_fsm_rules
12
+ from .rules import lower_rule_items
13
+
14
+ __all__ = [
15
+ "build_state_constants",
16
+ "combine_guards",
17
+ "guard_negation",
18
+ "lower_fsm_rules",
19
+ "lower_rule_items",
20
+ "make_transition_rule",
21
+ "normalize_rule_items",
22
+ "parse_structured_rule",
23
+ "with_guard",
24
+ ]