nc-gcode-interpreter 0.2.1__tar.gz → 0.2.3__tar.gz

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 (119) hide show
  1. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/CHANGELOG.md +36 -0
  2. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/Cargo.lock +1 -1
  3. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/Cargo.toml +1 -1
  4. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/PKG-INFO +1 -1
  5. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/nc_gcode_interpreter/__init__.py +31 -0
  6. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/nc_gcode_interpreter/_internal.pyi +12 -1
  7. nc_gcode_interpreter-0.2.3/python/tests/test_batch_variables.py +205 -0
  8. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_structured_errors.py +41 -0
  9. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/errors.rs +240 -8
  10. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/interpret_rules.rs +25 -6
  11. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/interpreter.rs +16 -2
  12. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/lib.rs +98 -24
  13. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/output.rs +113 -3
  14. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/state.rs +34 -9
  15. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/.github/workflows/build-and-release.yml +0 -0
  16. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/.gitignore +0 -0
  17. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/CONTRIBUTING.md +0 -0
  18. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/Development.md +0 -0
  19. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/LICENSE +0 -0
  20. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/README.md +0 -0
  21. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/TODO.md +0 -0
  22. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/docs/sinumerik-execution-model.md +0 -0
  23. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/actual_position.csv +0 -0
  24. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/actual_position.mpf +0 -0
  25. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/arc.csv +0 -0
  26. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/arc.mpf +0 -0
  27. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/arrays.csv +0 -0
  28. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/arrays.mpf +0 -0
  29. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/axis_index_assignment.csv +0 -0
  30. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/axis_index_assignment.mpf +0 -0
  31. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/basic_math.csv +0 -0
  32. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/basic_math.mpf +0 -0
  33. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/case.csv +0 -0
  34. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/case.mpf +0 -0
  35. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/case_insensitive_variables.csv +0 -0
  36. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/case_insensitive_variables.mpf +0 -0
  37. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/custom_vars.csv +0 -0
  38. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/custom_vars.mpf +0 -0
  39. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/def_string.csv +0 -0
  40. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/def_string.mpf +0 -0
  41. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/defaults.csv +0 -0
  42. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/defaults.mpf +0 -0
  43. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/edge_cases.csv +0 -0
  44. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/edge_cases.mpf +0 -0
  45. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/flattening/flatten_demo.mpf +0 -0
  46. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/flattening/flatten_demo_flattened.csv +0 -0
  47. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/flattening/flatten_demo_raw.csv +0 -0
  48. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/for_loop.csv +0 -0
  49. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/for_loop.mpf +0 -0
  50. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/function_calls.csv +0 -0
  51. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/function_calls.mpf +0 -0
  52. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/goto.csv +0 -0
  53. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/goto.mpf +0 -0
  54. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/if_statement.csv +0 -0
  55. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/if_statement.mpf +0 -0
  56. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/increment.csv +0 -0
  57. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/increment.mpf +0 -0
  58. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/logic_operators.csv +0 -0
  59. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/logic_operators.mpf +0 -0
  60. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/loop.csv +0 -0
  61. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/loop.mpf +0 -0
  62. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/multiple_m_codes.csv +0 -0
  63. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/multiple_m_codes.mpf +0 -0
  64. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/precedence.csv +0 -0
  65. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/precedence.mpf +0 -0
  66. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/r_param.csv +0 -0
  67. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/r_param.mpf +0 -0
  68. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/repeat.csv +0 -0
  69. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/repeat.mpf +0 -0
  70. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/simple.csv +0 -0
  71. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/simple.mpf +0 -0
  72. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/spline.csv +0 -0
  73. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/spline.mpf +0 -0
  74. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/tool.csv +0 -0
  75. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/tool.mpf +0 -0
  76. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/trans.csv +0 -0
  77. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/trans.mpf +0 -0
  78. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/trans2.csv +0 -0
  79. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/trans2.mpf +0 -0
  80. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/trans_ic.csv +0 -0
  81. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/trans_ic.mpf +0 -0
  82. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/trans_reset.csv +0 -0
  83. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/trans_reset.mpf +0 -0
  84. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/trig.csv +0 -0
  85. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/trig.mpf +0 -0
  86. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/variables.csv +0 -0
  87. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/examples/variables.mpf +0 -0
  88. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/ggroups/generate_g_commands.py +0 -0
  89. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/pyproject.toml +0 -0
  90. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/example/minimal.py +0 -0
  91. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/example/streaming.py +0 -0
  92. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/nc_gcode_interpreter/cli.py +0 -0
  93. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/nc_gcode_interpreter/ggroups.json +0 -0
  94. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/nc_gcode_interpreter/py.typed +0 -0
  95. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/nc_gcode_interpreter/viz.py +0 -0
  96. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_arithmetic_functions.py +0 -0
  97. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_batches.py +0 -0
  98. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_diagnostics.py +0 -0
  99. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_expected_output.py +0 -0
  100. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_field_defects.py +0 -0
  101. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_flags.py +0 -0
  102. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_flatten.py +0 -0
  103. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_g_groups.py +0 -0
  104. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_g_vocabulary.py +0 -0
  105. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_jumps.py +0 -0
  106. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_real_comparisons.py +0 -0
  107. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_stage1.py +0 -0
  108. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_streaming.py +0 -0
  109. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/python/tests/test_unsupported.py +0 -0
  110. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/rustfmt.toml +0 -0
  111. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/flatten.rs +0 -0
  112. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/grammar.pest +0 -0
  113. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/line_driver.rs +0 -0
  114. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/main.rs +0 -0
  115. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/modal_groups.rs +0 -0
  116. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/structure_scan.rs +0 -0
  117. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/src/types.rs +0 -0
  118. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/tests/cli.rs +0 -0
  119. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.3}/uv.lock +0 -0
@@ -4,6 +4,42 @@ Notable changes to **nc-gcode-interpreter**. The format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions are git tags,
5
5
  released to PyPI.
6
6
 
7
+ ## [v0.2.3] - 2026-07-07
8
+
9
+ ### Added
10
+
11
+ - `NcError.kind`: a stable, machine-readable string discriminating the error
12
+ class (e.g. `"unexpected_axis"`, `"undefined_variable"`, `"unknown_g_command"`,
13
+ `"parse_context"`), so a consumer can branch on the kind of error without
14
+ string-matching the formatted message. Present on every `NcError` alongside
15
+ the existing `line` / `column` / `context` / `line_text` location attributes
16
+ (#56).
17
+
18
+ ### Fixed
19
+
20
+ - Validation/semantic errors now carry their source location. `Unexpected axis`,
21
+ and the "axis/reserved name used as a variable" definition errors, previously
22
+ raised an `NcError` with `line` / `column` / `context` / `line_text` all
23
+ `None`; they now anchor to the offending line (and expose its text), so an
24
+ editor can mark the exact spot - matching what syntactic parse errors already
25
+ did (#56).
26
+
27
+ ## [v0.2.2] - 2026-07-07
28
+
29
+ ### Added
30
+
31
+ - Variable-change events on the batch path: `nc_to_batches(...,
32
+ include_variables=True)` exposes, once exhausted, a sparse `variable_events`
33
+ DataFrame (`row_idx` / `name_id` / `value`) plus a `variable_names` list -
34
+ the batch-path twin of the per-row `variables` dict `nc_to_rows` already
35
+ yields. `row_idx` is the output-row index a change is seen at (a change on a
36
+ variable-only block is attributed to the next output row), so replaying the
37
+ events reconstructs the symbol table at any row. Off by default (no cost).
38
+ - `string_table` in the interpreter state dict: `DEF STRING` variables now
39
+ round-trip into `.state` / the `nc_to_dataframe` state tuple as
40
+ `state["string_table"]` (`dict[str, str]`), alongside the existing `axes` /
41
+ `symbol_table` / `translation` numeric tables (previously omitted).
42
+
7
43
  ## [v0.2.1] - 2026-07-07
8
44
 
9
45
  ### Added
@@ -506,7 +506,7 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
506
506
 
507
507
  [[package]]
508
508
  name = "nc-gcode-interpreter"
509
- version = "0.2.1"
509
+ version = "0.2.3"
510
510
  dependencies = [
511
511
  "arrow-array",
512
512
  "arrow-data",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "nc-gcode-interpreter"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nc-gcode-interpreter
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: 3.12
@@ -498,6 +498,29 @@ class _BatchIterator:
498
498
  def state(self) -> dict | None:
499
499
  return self._inner.state
500
500
 
501
+ @property
502
+ def variable_events(self) -> "pl.DataFrame | None":
503
+ """Sparse variable-change events (``include_variables=True``).
504
+
505
+ A :class:`polars.DataFrame` with columns ``row_idx`` (the output-row
506
+ index the change is seen at), ``name_id`` (index into
507
+ :attr:`variable_names`) and ``value``. ``None`` unless the iterator was
508
+ created with ``include_variables=True``. Available once exhausted.
509
+ Replaying the events in order (decoding ``name_id`` via
510
+ :attr:`variable_names`) reconstructs the symbol table at any row, the
511
+ batch-path twin of the per-row ``variables`` dict ``nc_to_rows`` yields.
512
+ """
513
+ batch = self._inner.variable_events
514
+ return None if batch is None else pl.DataFrame(batch)
515
+
516
+ @property
517
+ def variable_names(self) -> list[str] | None:
518
+ """Variable names ``variable_events.name_id`` indexes into.
519
+
520
+ ``None`` unless ``include_variables=True``; available once exhausted.
521
+ """
522
+ return self._inner.variable_names
523
+
501
524
 
502
525
  def nc_to_batches(
503
526
  input: "TextFileLike | str | os.PathLike",
@@ -511,6 +534,7 @@ def nc_to_batches(
511
534
  allow_undefined_variables: bool = False,
512
535
  flatten_tolerance: float | None = None,
513
536
  include_line_numbers: bool = False,
537
+ include_variables: bool = False,
514
538
  ) -> _BatchIterator:
515
539
  """Interpret an NC program into a stream of columnar polars DataFrames.
516
540
 
@@ -541,6 +565,12 @@ def nc_to_batches(
541
565
  a loop, non-monotonic across a jump), never forward-filled. Default False
542
566
  leaves the schema unchanged.
543
567
 
568
+ With ``include_variables=True`` the iterator additionally exposes
569
+ ``variable_events`` (a sparse :class:`polars.DataFrame` of ``row_idx`` /
570
+ ``name_id`` / ``value``) and ``variable_names`` once exhausted — the
571
+ batch-path equivalent of the per-row ``variables`` dict :func:`nc_to_rows`
572
+ yields; replaying the events reconstructs the symbol table at any row.
573
+
544
574
  Example:
545
575
  --------
546
576
  >>> batches = nc_to_batches("G1 X10\\nX20 Y5", batch_size=1)
@@ -564,5 +594,6 @@ def nc_to_batches(
564
594
  input_is_path,
565
595
  flatten_tolerance,
566
596
  include_line_numbers,
597
+ include_variables,
567
598
  )
568
599
  return _BatchIterator(inner)
@@ -9,6 +9,10 @@ class NcError(ValueError):
9
9
  """NC parse/interpret error carrying structured location data. Subclasses
10
10
  ValueError so `except ValueError` keeps working."""
11
11
 
12
+ #: Stable, machine-readable discriminator for the error class (e.g.
13
+ #: ``"unexpected_axis"``, ``"undefined_variable"``), for branching without
14
+ #: matching the formatted message.
15
+ kind: str
12
16
  line: Optional[int]
13
17
  column: Optional[int]
14
18
  context: Optional[str]
@@ -43,8 +47,15 @@ def nc_to_batches(
43
47
  input_is_path: bool = False,
44
48
  flatten_tolerance: Optional[float] = None,
45
49
  include_line_numbers: bool = False,
50
+ include_variables: bool = False,
46
51
  ) -> Any:
47
- """Interpret an NC program into an iterator of columnar polars DataFrames."""
52
+ """Interpret an NC program into an iterator of columnar polars DataFrames.
53
+
54
+ The returned iterator exposes ``state`` (dict with ``axes``,
55
+ ``symbol_table``, ``translation`` and ``string_table``) once exhausted, and
56
+ - when ``include_variables`` is set - ``variable_events`` (an Arrow batch of
57
+ ``row_idx`` / ``name_id`` / ``value``) and ``variable_names`` (list[str]).
58
+ """
48
59
  ...
49
60
 
50
61
  __all__ = ["nc_to_rows", "nc_to_batches"]
@@ -0,0 +1,205 @@
1
+ """Batch-path variable-change events (#46) + `string_table` in the state dict.
2
+
3
+ The streaming `nc_to_rows(include_variables=True)` path yields a per-row
4
+ `variables` dict; the batch path (`nc_to_batches` / `nc_to_dataframe`) now
5
+ surfaces the same information as a sparse side-table (`row_idx`, `name_id`,
6
+ `value`) plus a `variable_names` list. These tests pin (a) parity of the
7
+ ordered assignment sequence between the two paths and (b) that `DEF STRING`
8
+ variables round-trip into the returned state dict.
9
+ """
10
+
11
+ import polars as pl
12
+
13
+ from nc_gcode_interpreter import nc_to_batches, nc_to_dataframe, nc_to_rows
14
+
15
+
16
+ def _stream_sequence(program: str) -> list[tuple[str, float]]:
17
+ """Flat, ordered `(name, value)` assignment sequence from the stream."""
18
+ return [
19
+ (name, value)
20
+ for _line, _row, variables in nc_to_rows(program, include_variables=True)
21
+ for name, value in variables.items()
22
+ ]
23
+
24
+
25
+ def _batch_sequence(it) -> list[tuple[str, float]]:
26
+ """Flat, ordered `(name, value)` sequence decoded from the batch events."""
27
+ events = it.variable_events
28
+ names = it.variable_names
29
+ return [
30
+ (names[name_id], value)
31
+ for name_id, value in zip(events["name_id"], events["value"])
32
+ ]
33
+
34
+
35
+ def test_batch_variable_events_match_stream_simple():
36
+ program = "\n".join(
37
+ [
38
+ "DEF REAL Q=2.5", # variable-only
39
+ "R1=0", # variable-only
40
+ "X=R1", # output row, no change
41
+ "X=Q", # output row, no change
42
+ ]
43
+ )
44
+ it = nc_to_batches(program, include_variables=True)
45
+ list(it) # exhaust to populate variable_events / state
46
+ assert _batch_sequence(it) == _stream_sequence(program)
47
+
48
+
49
+ def test_batch_variable_events_match_stream_while_loop():
50
+ # A WHILE loop where R1 changes once per iteration: the per-iteration
51
+ # counter events must appear on the batch path exactly as they do on the
52
+ # stream, in the same order.
53
+ program = "\n".join(
54
+ [
55
+ "DEF REAL Q=2.5",
56
+ "R1=0",
57
+ "WHILE R1<3",
58
+ "X=R1 Q=Q*2", # output row that ALSO assigns a variable
59
+ "R1=R1+1", # variable-only, per iteration
60
+ "ENDWHILE",
61
+ "X=Q",
62
+ ]
63
+ )
64
+ it = nc_to_batches(program, include_variables=True)
65
+ list(it)
66
+ assert _batch_sequence(it) == _stream_sequence(program)
67
+
68
+
69
+ def test_batch_variable_events_row_idx_reconstructs_symbol_table():
70
+ # Replaying every event reconstructs the final symbol table (minus the
71
+ # built-in TRUE/FALSE), mirroring the streaming accumulation invariant.
72
+ program = "\n".join(
73
+ [
74
+ "DEF REAL Q=1",
75
+ "R1=0",
76
+ "WHILE R1<3",
77
+ "X=R1 Q=Q*2",
78
+ "R1=R1+1",
79
+ "ENDWHILE",
80
+ ]
81
+ )
82
+ it = nc_to_batches(program, include_variables=True)
83
+ list(it)
84
+ accumulated: dict[str, float] = {}
85
+ for name, value in _batch_sequence(it):
86
+ accumulated[name] = value
87
+ symbol_table = dict(it.state["symbol_table"])
88
+ for builtin in ("TRUE", "FALSE"):
89
+ symbol_table.pop(builtin)
90
+ assert accumulated == symbol_table
91
+
92
+ # row_idx is monotonic non-decreasing (events arrive in program order) and
93
+ # bounded by the number of output rows.
94
+ events = it.variable_events
95
+ row_idx = events["row_idx"].to_list()
96
+ assert row_idx == sorted(row_idx)
97
+
98
+
99
+ def test_batch_variable_events_row_idx_aligns_with_output_rows():
100
+ # A change on a variable-only block is attributed to the NEXT output row;
101
+ # a change on an output row gets that row's own index.
102
+ program = "\n".join(
103
+ [
104
+ "R1=0", # variable-only, before output row 0
105
+ "X=R1", # output row 0
106
+ "R1=1", # variable-only, before output row 1
107
+ "X=R1", # output row 1
108
+ ]
109
+ )
110
+ it = nc_to_batches(program, include_variables=True)
111
+ list(it)
112
+ events = it.variable_events
113
+ names = it.variable_names
114
+ decoded = [
115
+ (row_idx, names[name_id], value)
116
+ for row_idx, name_id, value in zip(
117
+ events["row_idx"], events["name_id"], events["value"]
118
+ )
119
+ ]
120
+ assert decoded == [(0, "R1", 0.0), (1, "R1", 1.0)]
121
+
122
+
123
+ def test_batch_variable_events_absent_without_flag():
124
+ it = nc_to_batches("R1=5\nX=R1")
125
+ list(it)
126
+ assert it.variable_events is None
127
+ assert it.variable_names is None
128
+
129
+
130
+ def test_batch_variable_events_empty_when_no_assignments():
131
+ it = nc_to_batches("G1 X10\nX20 Y5", include_variables=True)
132
+ list(it)
133
+ assert isinstance(it.variable_events, pl.DataFrame)
134
+ assert it.variable_events.height == 0
135
+ assert it.variable_names == []
136
+
137
+
138
+ def test_string_table_round_trips_through_state_dict():
139
+ program = "\n".join(
140
+ [
141
+ "DEF STRING[16] MSG",
142
+ 'MSG="HELLO WORLD"',
143
+ "DEF STRING[8] TAG",
144
+ 'TAG="ABC"',
145
+ "G1 X1",
146
+ ]
147
+ )
148
+ _df, state = nc_to_dataframe(program)
149
+ assert state["string_table"] == {"MSG": "HELLO WORLD", "TAG": "ABC"}
150
+ # The existing numeric sub-tables are untouched by the new key.
151
+ assert set(state) == {"axes", "symbol_table", "translation", "string_table"}
152
+ # The string variable never leaks into the numeric symbol table.
153
+ assert "MSG" not in state["symbol_table"]
154
+
155
+
156
+ def test_string_table_on_streaming_and_batch_iterators():
157
+ program = 'DEF STRING[8] MSG\nMSG="HI"\nG1 X1'
158
+ rows = nc_to_rows(program)
159
+ list(rows)
160
+ assert rows.state["string_table"] == {"MSG": "HI"}
161
+
162
+ it = nc_to_batches(program)
163
+ list(it)
164
+ assert it.state["string_table"] == {"MSG": "HI"}
165
+
166
+
167
+ def test_line_numbers_and_variables_compose_on_the_batch_path():
168
+ # include_line_numbers (#45) and include_variables (#46) share the batch
169
+ # path and were merged independently; requesting both at once must give the
170
+ # leading `line_no` column AND the sparse variable-event side-table, with
171
+ # `row_idx` still aligned to the emitted (line_no-carrying) output rows.
172
+ program = "\n".join(
173
+ [
174
+ "R1=0", # variable-only, before output row 0
175
+ "WHILE R1<3",
176
+ "X=R1", # output row (loop body, source line 3)
177
+ "R1=R1+1", # variable-only
178
+ "ENDWHILE",
179
+ "X9", # output row, source line 6
180
+ ]
181
+ )
182
+ it = nc_to_batches(
183
+ program,
184
+ batch_size=1_000_000,
185
+ include_line_numbers=True,
186
+ include_variables=True,
187
+ )
188
+ df = pl.concat(list(it), how="diagonal")
189
+
190
+ # line_no leads and tracks source lines (loop body repeats line 3).
191
+ assert df.columns[0] == "line_no"
192
+ assert df["line_no"].to_list() == [3, 3, 3, 6]
193
+ assert df["X"].to_list() == [0.0, 1.0, 2.0, 9.0]
194
+
195
+ # Variable events still decode independently of the line_no column.
196
+ names = it.variable_names
197
+ decoded = [
198
+ (row_idx, names[name_id], value)
199
+ for row_idx, name_id, value in zip(
200
+ it.variable_events["row_idx"],
201
+ it.variable_events["name_id"],
202
+ it.variable_events["value"],
203
+ )
204
+ ]
205
+ assert decoded == [(0, "R1", 0.0), (1, "R1", 1.0), (2, "R1", 2.0), (3, "R1", 3.0)]
@@ -48,3 +48,44 @@ def test_str_is_still_the_full_message():
48
48
  with pytest.raises(NcError) as exc:
49
49
  nc_to_dataframe("X=R99\n")
50
50
  assert "Undefined variable" in str(exc.value)
51
+
52
+
53
+ def test_kind_is_a_stable_machine_readable_discriminator():
54
+ # #56: consumers branch on `kind` instead of string-matching the message.
55
+ with pytest.raises(NcError) as exc:
56
+ nc_to_dataframe("X=R99\n")
57
+ assert exc.value.kind == "undefined_variable"
58
+
59
+ with pytest.raises(NcError) as exc:
60
+ nc_to_dataframe("G999 X1\n")
61
+ assert exc.value.kind == "unknown_g_command"
62
+
63
+ # Present (and stable) on every NcError, alongside the location attrs.
64
+ with pytest.raises(NcError) as exc:
65
+ nc_to_dataframe("G1 X10\nX20 Y((\n")
66
+ assert exc.value.kind == "parse_context"
67
+
68
+
69
+ def test_validation_error_carries_kind_and_location():
70
+ # #56: an undeclared axis is a validation error; it now reports both a
71
+ # stable kind AND the source location (previously all-None).
72
+ with pytest.raises(NcError) as exc:
73
+ # Q is not a declared axis; the frame instruction is on line 2.
74
+ nc_to_dataframe("G1 X0 Y0 F100\nTRANS Q=10\n")
75
+ e = exc.value
76
+ assert e.kind == "unexpected_axis"
77
+ assert e.line == 2
78
+ assert e.column is None
79
+ assert e.line_text is not None and "Q=10" in e.line_text
80
+ assert "Unexpected axis" in str(e)
81
+
82
+
83
+ def test_axis_used_as_variable_carries_location():
84
+ # Declaring a variable with an axis name is a validation error that now
85
+ # anchors to the offending DEF line.
86
+ with pytest.raises(NcError) as exc:
87
+ nc_to_dataframe("DEF REAL X\n")
88
+ e = exc.value
89
+ assert e.kind == "axis_used_as_variable"
90
+ assert e.line == 1
91
+ assert e.line_text is not None and "X" in e.line_text
@@ -99,11 +99,26 @@ To fix this, ensure that each block contains at most one M command.
99
99
  message: String,
100
100
  },
101
101
  #[error("Unexpected axis '{axis}'. Valid axes are: {axes}")]
102
- UnexpectedAxis { axis: String, axes: String },
102
+ UnexpectedAxis {
103
+ axis: String,
104
+ axes: String,
105
+ /// Source location of the offending axis word. Not shown in the
106
+ /// formatted message, only exposed as data (see [`ParsingError::location`]).
107
+ line_no: usize,
108
+ preview: String,
109
+ },
103
110
  #[error("Cannot define a variable named '{name}', as it conflicts with an axis name")]
104
- AxisUsedAsVariable { name: String },
111
+ AxisUsedAsVariable {
112
+ name: String,
113
+ line_no: usize,
114
+ preview: String,
115
+ },
105
116
  #[error("Cannot define a variable named '{name}', as it is a reserved block address (spline PW/SD/PL)")]
106
- ReservedNameUsedAsVariable { name: String },
117
+ ReservedNameUsedAsVariable {
118
+ name: String,
119
+ line_no: usize,
120
+ preview: String,
121
+ },
107
122
  #[error(
108
123
  r#"
109
124
  Missing axis mapping on line {line_no}
@@ -280,16 +295,48 @@ impl ParsingError {
280
295
  | Self::JumpTargetNotFound { line_no, preview, .. }
281
296
  | Self::UnmatchedStructure { line_no, preview, .. }
282
297
  | Self::UnknownGCommand { line_no, preview, .. }
283
- | Self::InvalidFunctionArity { line_no, preview, .. } => some(*line_no, None, None, Some(preview)),
298
+ | Self::InvalidFunctionArity { line_no, preview, .. }
299
+ // Semantic/validation errors: the offending block is known at raise
300
+ // time, so they anchor to a line (no column) like the others above.
301
+ | Self::UnexpectedAxis { line_no, preview, .. }
302
+ | Self::AxisUsedAsVariable { line_no, preview, .. }
303
+ | Self::ReservedNameUsedAsVariable { line_no, preview, .. } => some(*line_no, None, None, Some(preview)),
284
304
  Self::ParseError { .. }
285
305
  | Self::InvalidElementCount { .. }
286
306
  | Self::InvalidCondition
287
307
  | Self::UnexpectedOperator { .. }
288
308
  | Self::LoopLimit { .. }
289
- | Self::StreamClosed
290
- | Self::UnexpectedAxis { .. }
291
- | Self::AxisUsedAsVariable { .. }
292
- | Self::ReservedNameUsedAsVariable { .. } => None,
309
+ | Self::StreamClosed => None,
310
+ }
311
+ }
312
+
313
+ /// A stable, machine-readable discriminator for the error class, so a
314
+ /// consumer can branch on the kind of error without string-matching the
315
+ /// formatted message. Exposed to Python as the `NcError.kind` attribute.
316
+ /// These strings are part of the public API: keep them stable.
317
+ pub fn kind(&self) -> &'static str {
318
+ match self {
319
+ Self::ParsingContext { .. } => "parse_context",
320
+ Self::UnknownVariable { .. } => "unknown_variable",
321
+ Self::UndefinedVariable { .. } => "undefined_variable",
322
+ Self::UnexpectedRule { .. } => "unexpected_rule",
323
+ Self::ParseError { .. } => "parse_error",
324
+ Self::InvalidElementCount { .. } => "invalid_element_count",
325
+ Self::InvalidCondition => "invalid_condition",
326
+ Self::UnexpectedOperator { .. } => "unexpected_operator",
327
+ Self::LoopLimit { .. } => "loop_limit",
328
+ Self::StreamClosed => "stream_closed",
329
+ Self::TooManyMCommands { .. } => "too_many_m_commands",
330
+ Self::UnexpectedAxis { .. } => "unexpected_axis",
331
+ Self::AxisUsedAsVariable { .. } => "axis_used_as_variable",
332
+ Self::ReservedNameUsedAsVariable { .. } => "reserved_name_used_as_variable",
333
+ Self::MissingAxisMapping { .. } => "missing_axis_mapping",
334
+ Self::InvalidAxisIndex { .. } => "invalid_axis_index",
335
+ Self::UnsupportedStatement { .. } => "unsupported_statement",
336
+ Self::JumpTargetNotFound { .. } => "jump_target_not_found",
337
+ Self::UnmatchedStructure { .. } => "unmatched_structure",
338
+ Self::UnknownGCommand { .. } => "unknown_g_command",
339
+ Self::InvalidFunctionArity { .. } => "invalid_function_arity",
293
340
  }
294
341
  }
295
342
  }
@@ -333,4 +380,189 @@ mod tests {
333
380
  assert!(ParsingError::StreamClosed.location().is_none());
334
381
  assert!(ParsingError::InvalidCondition.location().is_none());
335
382
  }
383
+
384
+ #[test]
385
+ fn validation_errors_now_carry_a_location() {
386
+ // #56: semantic/validation errors (previously location-less) anchor to
387
+ // the offending line so an editor can mark the spot.
388
+ let axis = ParsingError::UnexpectedAxis {
389
+ axis: "QQ".to_string(),
390
+ axes: "X, Y, Z".to_string(),
391
+ line_no: 4,
392
+ preview: "TRANS QQ10".to_string(),
393
+ };
394
+ let loc = axis.location().expect("UnexpectedAxis now has a location");
395
+ assert_eq!((loc.line, loc.column), (4, None));
396
+ assert_eq!(loc.line_text.as_deref(), Some("TRANS QQ10"));
397
+
398
+ let dup = ParsingError::AxisUsedAsVariable {
399
+ name: "X".to_string(),
400
+ line_no: 2,
401
+ preview: "DEF REAL X".to_string(),
402
+ };
403
+ assert_eq!(dup.location().expect("has location").line, 2);
404
+ }
405
+
406
+ #[test]
407
+ fn kind_is_a_stable_per_variant_discriminator() {
408
+ // Exhaustive: `kind()` strings are public API, so pin every variant's
409
+ // value. The wildcard-free match in `kind()` forces a new variant to be
410
+ // handled there; this list forces its string to be chosen deliberately.
411
+ use crate::types::Rule;
412
+ let s = String::new;
413
+ let cases: Vec<(ParsingError, &str)> = vec![
414
+ (
415
+ ParsingError::ParsingContext {
416
+ line_no: 1,
417
+ column: None,
418
+ preview: s(),
419
+ context: s(),
420
+ message: s(),
421
+ },
422
+ "parse_context",
423
+ ),
424
+ (
425
+ ParsingError::UnknownVariable {
426
+ line_no: 1,
427
+ preview: s(),
428
+ variable: s(),
429
+ },
430
+ "unknown_variable",
431
+ ),
432
+ (
433
+ ParsingError::UndefinedVariable {
434
+ line_no: 1,
435
+ preview: s(),
436
+ name: s(),
437
+ },
438
+ "undefined_variable",
439
+ ),
440
+ (
441
+ ParsingError::UnexpectedRule {
442
+ rule: Rule::EOI,
443
+ context: s(),
444
+ line_no: 1,
445
+ preview: s(),
446
+ message: s(),
447
+ },
448
+ "unexpected_rule",
449
+ ),
450
+ (ParsingError::ParseError { message: s() }, "parse_error"),
451
+ (
452
+ ParsingError::InvalidElementCount { expected: 1, actual: 2 },
453
+ "invalid_element_count",
454
+ ),
455
+ (ParsingError::InvalidCondition, "invalid_condition"),
456
+ (
457
+ ParsingError::UnexpectedOperator { operator: s() },
458
+ "unexpected_operator",
459
+ ),
460
+ (ParsingError::LoopLimit { limit: s() }, "loop_limit"),
461
+ (ParsingError::StreamClosed, "stream_closed"),
462
+ (
463
+ ParsingError::TooManyMCommands {
464
+ line_no: 1,
465
+ preview: s(),
466
+ message: s(),
467
+ },
468
+ "too_many_m_commands",
469
+ ),
470
+ (
471
+ ParsingError::UnexpectedAxis {
472
+ axis: s(),
473
+ axes: s(),
474
+ line_no: 1,
475
+ preview: s(),
476
+ },
477
+ "unexpected_axis",
478
+ ),
479
+ (
480
+ ParsingError::AxisUsedAsVariable {
481
+ name: s(),
482
+ line_no: 1,
483
+ preview: s(),
484
+ },
485
+ "axis_used_as_variable",
486
+ ),
487
+ (
488
+ ParsingError::ReservedNameUsedAsVariable {
489
+ name: s(),
490
+ line_no: 1,
491
+ preview: s(),
492
+ },
493
+ "reserved_name_used_as_variable",
494
+ ),
495
+ (
496
+ ParsingError::MissingAxisMapping {
497
+ line_no: 1,
498
+ preview: s(),
499
+ axis: s(),
500
+ },
501
+ "missing_axis_mapping",
502
+ ),
503
+ (
504
+ ParsingError::InvalidAxisIndex {
505
+ line_no: 1,
506
+ preview: s(),
507
+ axis: s(),
508
+ index: 0,
509
+ },
510
+ "invalid_axis_index",
511
+ ),
512
+ (
513
+ ParsingError::UnsupportedStatement {
514
+ line_no: 1,
515
+ preview: s(),
516
+ statement: s(),
517
+ hint: s(),
518
+ },
519
+ "unsupported_statement",
520
+ ),
521
+ (
522
+ ParsingError::JumpTargetNotFound {
523
+ line_no: 1,
524
+ preview: s(),
525
+ target: s(),
526
+ search_direction: s(),
527
+ hint: s(),
528
+ },
529
+ "jump_target_not_found",
530
+ ),
531
+ (
532
+ ParsingError::UnmatchedStructure {
533
+ line_no: 1,
534
+ preview: s(),
535
+ message: s(),
536
+ },
537
+ "unmatched_structure",
538
+ ),
539
+ (
540
+ ParsingError::UnknownGCommand {
541
+ line_no: 1,
542
+ preview: s(),
543
+ code: s(),
544
+ },
545
+ "unknown_g_command",
546
+ ),
547
+ (
548
+ ParsingError::InvalidFunctionArity {
549
+ line_no: 1,
550
+ preview: s(),
551
+ name: s(),
552
+ expected: 1,
553
+ actual: 2,
554
+ },
555
+ "invalid_function_arity",
556
+ ),
557
+ ];
558
+ for (err, expected) in &cases {
559
+ assert_eq!(err.kind(), *expected, "kind mismatch for {err:?}");
560
+ }
561
+ // Every kind string is distinct (no two variants share a discriminator).
562
+ let mut kinds: Vec<&str> = cases.iter().map(|(_, k)| *k).collect();
563
+ kinds.sort_unstable();
564
+ let unique = kinds.len();
565
+ kinds.dedup();
566
+ assert_eq!(kinds.len(), unique, "duplicate kind discriminator");
567
+ }
336
568
  }