nc-gcode-interpreter 0.2.1__tar.gz → 0.2.2__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.2}/CHANGELOG.md +16 -0
  2. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/Cargo.lock +1 -1
  3. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/Cargo.toml +1 -1
  4. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/PKG-INFO +1 -1
  5. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/nc_gcode_interpreter/__init__.py +31 -0
  6. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/nc_gcode_interpreter/_internal.pyi +8 -1
  7. nc_gcode_interpreter-0.2.2/python/tests/test_batch_variables.py +205 -0
  8. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/interpreter.rs +16 -2
  9. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/lib.rs +85 -16
  10. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/output.rs +113 -3
  11. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/state.rs +25 -8
  12. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/.github/workflows/build-and-release.yml +0 -0
  13. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/.gitignore +0 -0
  14. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/CONTRIBUTING.md +0 -0
  15. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/Development.md +0 -0
  16. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/LICENSE +0 -0
  17. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/README.md +0 -0
  18. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/TODO.md +0 -0
  19. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/docs/sinumerik-execution-model.md +0 -0
  20. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/actual_position.csv +0 -0
  21. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/actual_position.mpf +0 -0
  22. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/arc.csv +0 -0
  23. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/arc.mpf +0 -0
  24. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/arrays.csv +0 -0
  25. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/arrays.mpf +0 -0
  26. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/axis_index_assignment.csv +0 -0
  27. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/axis_index_assignment.mpf +0 -0
  28. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/basic_math.csv +0 -0
  29. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/basic_math.mpf +0 -0
  30. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/case.csv +0 -0
  31. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/case.mpf +0 -0
  32. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/case_insensitive_variables.csv +0 -0
  33. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/case_insensitive_variables.mpf +0 -0
  34. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/custom_vars.csv +0 -0
  35. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/custom_vars.mpf +0 -0
  36. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/def_string.csv +0 -0
  37. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/def_string.mpf +0 -0
  38. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/defaults.csv +0 -0
  39. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/defaults.mpf +0 -0
  40. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/edge_cases.csv +0 -0
  41. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/edge_cases.mpf +0 -0
  42. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/flattening/flatten_demo.mpf +0 -0
  43. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/flattening/flatten_demo_flattened.csv +0 -0
  44. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/flattening/flatten_demo_raw.csv +0 -0
  45. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/for_loop.csv +0 -0
  46. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/for_loop.mpf +0 -0
  47. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/function_calls.csv +0 -0
  48. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/function_calls.mpf +0 -0
  49. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/goto.csv +0 -0
  50. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/goto.mpf +0 -0
  51. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/if_statement.csv +0 -0
  52. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/if_statement.mpf +0 -0
  53. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/increment.csv +0 -0
  54. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/increment.mpf +0 -0
  55. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/logic_operators.csv +0 -0
  56. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/logic_operators.mpf +0 -0
  57. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/loop.csv +0 -0
  58. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/loop.mpf +0 -0
  59. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/multiple_m_codes.csv +0 -0
  60. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/multiple_m_codes.mpf +0 -0
  61. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/precedence.csv +0 -0
  62. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/precedence.mpf +0 -0
  63. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/r_param.csv +0 -0
  64. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/r_param.mpf +0 -0
  65. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/repeat.csv +0 -0
  66. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/repeat.mpf +0 -0
  67. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/simple.csv +0 -0
  68. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/simple.mpf +0 -0
  69. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/spline.csv +0 -0
  70. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/spline.mpf +0 -0
  71. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/tool.csv +0 -0
  72. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/tool.mpf +0 -0
  73. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/trans.csv +0 -0
  74. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/trans.mpf +0 -0
  75. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/trans2.csv +0 -0
  76. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/trans2.mpf +0 -0
  77. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/trans_ic.csv +0 -0
  78. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/trans_ic.mpf +0 -0
  79. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/trans_reset.csv +0 -0
  80. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/trans_reset.mpf +0 -0
  81. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/trig.csv +0 -0
  82. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/trig.mpf +0 -0
  83. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/variables.csv +0 -0
  84. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/examples/variables.mpf +0 -0
  85. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/ggroups/generate_g_commands.py +0 -0
  86. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/pyproject.toml +0 -0
  87. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/example/minimal.py +0 -0
  88. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/example/streaming.py +0 -0
  89. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/nc_gcode_interpreter/cli.py +0 -0
  90. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/nc_gcode_interpreter/ggroups.json +0 -0
  91. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/nc_gcode_interpreter/py.typed +0 -0
  92. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/nc_gcode_interpreter/viz.py +0 -0
  93. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_arithmetic_functions.py +0 -0
  94. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_batches.py +0 -0
  95. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_diagnostics.py +0 -0
  96. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_expected_output.py +0 -0
  97. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_field_defects.py +0 -0
  98. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_flags.py +0 -0
  99. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_flatten.py +0 -0
  100. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_g_groups.py +0 -0
  101. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_g_vocabulary.py +0 -0
  102. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_jumps.py +0 -0
  103. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_real_comparisons.py +0 -0
  104. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_stage1.py +0 -0
  105. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_streaming.py +0 -0
  106. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_structured_errors.py +0 -0
  107. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/python/tests/test_unsupported.py +0 -0
  108. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/rustfmt.toml +0 -0
  109. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/errors.rs +0 -0
  110. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/flatten.rs +0 -0
  111. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/grammar.pest +0 -0
  112. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/interpret_rules.rs +0 -0
  113. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/line_driver.rs +0 -0
  114. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/main.rs +0 -0
  115. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/modal_groups.rs +0 -0
  116. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/structure_scan.rs +0 -0
  117. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/src/types.rs +0 -0
  118. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/tests/cli.rs +0 -0
  119. {nc_gcode_interpreter-0.2.1 → nc_gcode_interpreter-0.2.2}/uv.lock +0 -0
@@ -4,6 +4,22 @@ 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.2] - 2026-07-07
8
+
9
+ ### Added
10
+
11
+ - Variable-change events on the batch path: `nc_to_batches(...,
12
+ include_variables=True)` exposes, once exhausted, a sparse `variable_events`
13
+ DataFrame (`row_idx` / `name_id` / `value`) plus a `variable_names` list -
14
+ the batch-path twin of the per-row `variables` dict `nc_to_rows` already
15
+ yields. `row_idx` is the output-row index a change is seen at (a change on a
16
+ variable-only block is attributed to the next output row), so replaying the
17
+ events reconstructs the symbol table at any row. Off by default (no cost).
18
+ - `string_table` in the interpreter state dict: `DEF STRING` variables now
19
+ round-trip into `.state` / the `nc_to_dataframe` state tuple as
20
+ `state["string_table"]` (`dict[str, str]`), alongside the existing `axes` /
21
+ `symbol_table` / `translation` numeric tables (previously omitted).
22
+
7
23
  ## [v0.2.1] - 2026-07-07
8
24
 
9
25
  ### 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.2"
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.2"
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.2
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)
@@ -43,8 +43,15 @@ def nc_to_batches(
43
43
  input_is_path: bool = False,
44
44
  flatten_tolerance: Optional[float] = None,
45
45
  include_line_numbers: bool = False,
46
+ include_variables: bool = False,
46
47
  ) -> Any:
47
- """Interpret an NC program into an iterator of columnar polars DataFrames."""
48
+ """Interpret an NC program into an iterator of columnar polars DataFrames.
49
+
50
+ The returned iterator exposes ``state`` (dict with ``axes``,
51
+ ``symbol_table``, ``translation`` and ``string_table``) once exhausted, and
52
+ - when ``include_variables`` is set - ``variable_events`` (an Arrow batch of
53
+ ``row_idx`` / ``name_id`` / ``value``) and ``variable_names`` (list[str]).
54
+ """
48
55
  ...
49
56
 
50
57
  __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)]
@@ -156,6 +156,8 @@ pub fn nc_to_batch_stream(
156
156
  flatten_tolerance: Option<f64>,
157
157
  batch_size: usize,
158
158
  sender: std::sync::mpsc::SyncSender<Table>,
159
+ include_variables: bool,
160
+ events_sender: std::sync::mpsc::Sender<crate::output::VariableEvents>,
159
161
  ) -> Result<state::State, ParsingError> {
160
162
  // Back-compatible entry point: never emits the opt-in line_no column.
161
163
  nc_to_batch_stream_with_line_numbers(
@@ -171,6 +173,8 @@ pub fn nc_to_batch_stream(
171
173
  batch_size,
172
174
  false,
173
175
  sender,
176
+ include_variables,
177
+ events_sender,
174
178
  )
175
179
  }
176
180
 
@@ -191,6 +195,8 @@ pub fn nc_to_batch_stream_with_line_numbers(
191
195
  batch_size: usize,
192
196
  emit_line_no: bool,
193
197
  sender: std::sync::mpsc::SyncSender<Table>,
198
+ include_variables: bool,
199
+ events_sender: std::sync::mpsc::Sender<crate::output::VariableEvents>,
194
200
  ) -> Result<state::State, ParsingError> {
195
201
  let mut state = build_state(
196
202
  axis_identifiers,
@@ -203,7 +209,14 @@ pub fn nc_to_batch_stream_with_line_numbers(
203
209
  let mut discard = OutputRows::collect();
204
210
  interpret_file(initial_state, &mut state, &mut discard)?;
205
211
  }
206
- let mut output = OutputRows::batch_stream_with_line_numbers(sender, batch_size, disable_forward_fill, emit_line_no);
212
+ let mut output = OutputRows::batch_stream_with_line_numbers(
213
+ sender,
214
+ batch_size,
215
+ disable_forward_fill,
216
+ include_variables,
217
+ events_sender,
218
+ emit_line_no,
219
+ );
207
220
  install_flattener(&mut output, &state, flatten_tolerance)?;
208
221
  interpret_file(input, &mut state, &mut output)?;
209
222
  output.finish()?;
@@ -584,8 +597,9 @@ mod tests {
584
597
  /// emitted [`Table`] carries the whole program's `line_no` column.
585
598
  fn interpret_with_line_numbers(input: &str) -> Table {
586
599
  let (tx, rx) = std::sync::mpsc::sync_channel(64);
600
+ let (events_tx, _events_rx) = std::sync::mpsc::channel();
587
601
  nc_to_batch_stream_with_line_numbers(
588
- input, None, None, None, 10000, false, None, false, None, 1_000_000, true, tx,
602
+ input, None, None, None, 10000, false, None, false, None, 1_000_000, true, tx, false, events_tx,
589
603
  )
590
604
  .expect("program should interpret");
591
605
  rx.into_iter().next().expect("one batch for a small program")
@@ -60,7 +60,8 @@ mod python_bindings {
60
60
 
61
61
  use crate::errors::ErrorLocation;
62
62
  use crate::interpreter::{nc_to_batch_stream_with_line_numbers, nc_to_row_stream};
63
- use crate::output::{is_forward_filled_column, is_string_column, Column, Row, Table};
63
+ use crate::output::{is_forward_filled_column, is_string_column, Column, Row, Table, VariableEvents};
64
+ use crate::state::FinalState;
64
65
  use crate::types::Value;
65
66
 
66
67
  pyo3::create_exception!(
@@ -108,6 +109,19 @@ mod python_bindings {
108
109
  }
109
110
  }
110
111
 
112
+ /// Render a [`FinalState`] as the Python `.state` dict:
113
+ /// `{axes, symbol_table, translation, string_table}`. The three numeric
114
+ /// sub-tables become `dict[str, float]`; `string_table` (DEF STRING
115
+ /// variables) becomes `dict[str, str]`. Shared by both iterators.
116
+ fn final_state_to_py<'py>(py: Python<'py>, state: &FinalState) -> PyResult<Bound<'py, PyDict>> {
117
+ let dict = PyDict::new(py);
118
+ dict.set_item("axes", state.axes.clone())?;
119
+ dict.set_item("symbol_table", state.symbol_table.clone())?;
120
+ dict.set_item("translation", state.translation.clone())?;
121
+ dict.set_item("string_table", state.string_table.clone())?;
122
+ Ok(dict)
123
+ }
124
+
111
125
  /// Build one Arrow [`RecordBatch`](arrow::record_batch::RecordBatch) directly
112
126
  /// from an output [`Table`], in column order. Each [`Column`] becomes an Arrow
113
127
  /// array straight from its `Vec` (a `Vec<Option<f64>>` -> Float64,
@@ -214,7 +228,7 @@ mod python_bindings {
214
228
  row_sender,
215
229
  );
216
230
  let message = match outcome {
217
- Ok(state) => Ok(state.to_python_dict()),
231
+ Ok(state) => Ok(state.final_state()),
218
232
  // The consumer hung up: nothing to report to nobody.
219
233
  Err(crate::errors::ParsingError::StreamClosed) => return,
220
234
  Err(error) => Err(ErrInfo::from_error(&error)),
@@ -242,9 +256,11 @@ mod python_bindings {
242
256
  allow_undefined_variables: bool,
243
257
  flatten_tolerance: Option<f64>,
244
258
  emit_line_no: bool,
259
+ include_variables: bool,
245
260
  ) -> PyResult<(
246
261
  mpsc::Receiver<Table>,
247
262
  mpsc::Receiver<Result<FinalState, ErrInfo>>,
263
+ mpsc::Receiver<VariableEvents>,
248
264
  std::thread::JoinHandle<()>,
249
265
  )> {
250
266
  // A cap of 3 lets the worker build one batch ahead while the consumer
@@ -252,6 +268,10 @@ mod python_bindings {
252
268
  // buffering.
253
269
  let (batch_sender, batch_receiver) = mpsc::sync_channel::<Table>(3);
254
270
  let (result_sender, result_receiver) = mpsc::sync_channel::<Result<FinalState, ErrInfo>>(1);
271
+ // Variable-change events are a small sparse side-table sent once at the
272
+ // end; an unbounded channel so the worker never blocks emitting them
273
+ // (they are only read after the batch stream is drained).
274
+ let (events_sender, events_receiver) = mpsc::channel::<VariableEvents>();
255
275
 
256
276
  let handle = std::thread::Builder::new()
257
277
  .name("nc-interpreter".to_string())
@@ -269,9 +289,11 @@ mod python_bindings {
269
289
  batch_size,
270
290
  emit_line_no,
271
291
  batch_sender,
292
+ include_variables,
293
+ events_sender,
272
294
  );
273
295
  let message = match outcome {
274
- Ok(state) => Ok(state.to_python_dict()),
296
+ Ok(state) => Ok(state.final_state()),
275
297
  // The consumer hung up: nothing to report to nobody.
276
298
  Err(crate::errors::ParsingError::StreamClosed) => return,
277
299
  Err(error) => Err(ErrInfo::from_error(&error)),
@@ -279,11 +301,9 @@ mod python_bindings {
279
301
  let _ = result_sender.send(message);
280
302
  })
281
303
  .map_err(|e| PyErr::new::<PyValueError, _>(format!("failed to spawn interpreter thread: {}", e)))?;
282
- Ok((batch_receiver, result_receiver, handle))
304
+ Ok((batch_receiver, result_receiver, events_receiver, handle))
283
305
  }
284
306
 
285
- type FinalState = HashMap<String, HashMap<String, f64>>;
286
-
287
307
  /// Iterator over interpreted rows: `next()` yields
288
308
  /// `(line_no, {column: value})` while the interpreter runs on a worker
289
309
  /// thread behind a bounded channel. Dropping the iterator hangs up the
@@ -440,11 +460,12 @@ mod python_bindings {
440
460
  }
441
461
  }
442
462
 
443
- /// The final interpreter state (axes, symbol_table, translation),
444
- /// available once the iterator is exhausted.
463
+ /// The final interpreter state
464
+ /// (`{axes, symbol_table, translation, string_table}`), available once
465
+ /// the iterator is exhausted.
445
466
  #[getter]
446
- fn state(&self) -> Option<FinalState> {
447
- self.state.clone()
467
+ fn state<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyDict>>> {
468
+ self.state.as_ref().map(|s| final_state_to_py(py, s)).transpose()
448
469
  }
449
470
  }
450
471
 
@@ -512,8 +533,14 @@ mod python_bindings {
512
533
  struct NcBatchIterator {
513
534
  batches: Option<Mutex<mpsc::Receiver<Table>>>,
514
535
  result: Option<Mutex<mpsc::Receiver<Result<FinalState, ErrInfo>>>>,
536
+ /// Set at construction only when `include_variables`; drained at finish
537
+ /// into `variable_events`.
538
+ events: Option<Mutex<mpsc::Receiver<VariableEvents>>>,
515
539
  handle: Option<std::thread::JoinHandle<()>>,
516
540
  state: Option<FinalState>,
541
+ /// The sparse variable-change side-table, available once exhausted.
542
+ /// `None` unless `include_variables` was set.
543
+ variable_events: Option<VariableEvents>,
517
544
  }
518
545
 
519
546
  impl NcBatchIterator {
@@ -529,6 +556,16 @@ mod python_bindings {
529
556
  Err(_) => {}
530
557
  }
531
558
  }
559
+ // The worker sends the events (once) before dropping the batch
560
+ // sender, so by the time the batch stream has closed they are
561
+ // already queued on the unbounded channel; a hung-up / disabled run
562
+ // yields `Err` and leaves `variable_events` as `None`.
563
+ if let Some(events) = self.events.take() {
564
+ let receiver = events.into_inner().expect("events receiver mutex poisoned");
565
+ if let Ok(ev) = py.detach(move || receiver.recv()) {
566
+ self.variable_events = Some(ev);
567
+ }
568
+ }
532
569
  self.join();
533
570
  Ok(())
534
571
  }
@@ -579,11 +616,33 @@ mod python_bindings {
579
616
  }
580
617
  }
581
618
 
582
- /// The final interpreter state (axes, symbol_table, translation),
583
- /// available once the iterator is exhausted.
619
+ /// The final interpreter state
620
+ /// (`{axes, symbol_table, translation, string_table}`), available once
621
+ /// the iterator is exhausted.
584
622
  #[getter]
585
- fn state(&self) -> Option<FinalState> {
586
- self.state.clone()
623
+ fn state<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyDict>>> {
624
+ self.state.as_ref().map(|s| final_state_to_py(py, s)).transpose()
625
+ }
626
+
627
+ /// The sparse variable-change events (`include_variables=True`) as an
628
+ /// Arrow record batch with columns `row_idx` (Int64, the output-row
629
+ /// index the change is seen at), `name_id` (Int64, index into
630
+ /// `variable_names`) and `value` (Float64). `None` when the iterator was
631
+ /// created without `include_variables`. Available once exhausted.
632
+ /// `pl.DataFrame(it.variable_events)` wraps it zero-copy.
633
+ #[getter]
634
+ fn variable_events(&self) -> PyResult<Option<ArrowBatch>> {
635
+ self.variable_events
636
+ .as_ref()
637
+ .map(|ev| table_to_arrow_batch(ev.to_table()))
638
+ .transpose()
639
+ }
640
+
641
+ /// The variable names `name_id` indexes into (in first-seen order).
642
+ /// `None` unless `include_variables` was set; available once exhausted.
643
+ #[getter]
644
+ fn variable_names(&self) -> Option<Vec<String>> {
645
+ self.variable_events.as_ref().map(|ev| ev.names.clone())
587
646
  }
588
647
  }
589
648
 
@@ -593,7 +652,7 @@ mod python_bindings {
593
652
  /// thread. Wrapping each with `pl.DataFrame` and concatenating reconstructs
594
653
  /// `nc_to_dataframe`.
595
654
  #[pyfunction]
596
- #[pyo3(signature = (input, batch_size = 500_000, initial_state = None, axis_identifiers = None, extra_axes = None, iteration_limit = 10000, disable_forward_fill = false, axis_index_map = None, allow_undefined_variables = false, input_is_path = false, flatten_tolerance = None, include_line_numbers = false))]
655
+ #[pyo3(signature = (input, batch_size = 500_000, initial_state = None, axis_identifiers = None, extra_axes = None, iteration_limit = 10000, disable_forward_fill = false, axis_index_map = None, allow_undefined_variables = false, input_is_path = false, flatten_tolerance = None, include_line_numbers = false, include_variables = false))]
597
656
  #[allow(clippy::too_many_arguments)]
598
657
  fn nc_to_batches(
599
658
  input: String,
@@ -608,6 +667,7 @@ mod python_bindings {
608
667
  input_is_path: bool,
609
668
  flatten_tolerance: Option<f64>,
610
669
  include_line_numbers: bool,
670
+ include_variables: bool,
611
671
  ) -> PyResult<NcBatchIterator> {
612
672
  if batch_size == 0 {
613
673
  return Err(PyErr::new::<PyValueError, _>("batch_size must be greater than 0"));
@@ -619,7 +679,7 @@ mod python_bindings {
619
679
  } else {
620
680
  input
621
681
  };
622
- let (batch_receiver, result_receiver, handle) = spawn_batch_stream(
682
+ let (batch_receiver, result_receiver, events_receiver, handle) = spawn_batch_stream(
623
683
  input,
624
684
  batch_size,
625
685
  initial_state,
@@ -631,13 +691,22 @@ mod python_bindings {
631
691
  allow_undefined_variables,
632
692
  flatten_tolerance,
633
693
  include_line_numbers,
694
+ include_variables,
634
695
  )?;
635
696
 
636
697
  Ok(NcBatchIterator {
637
698
  batches: Some(Mutex::new(batch_receiver)),
638
699
  result: Some(Mutex::new(result_receiver)),
700
+ // Only hold the events receiver when recording; otherwise leave it
701
+ // `None` so `.variable_events` stays `None`.
702
+ events: if include_variables {
703
+ Some(Mutex::new(events_receiver))
704
+ } else {
705
+ None
706
+ },
639
707
  handle: Some(handle),
640
708
  state: None,
709
+ variable_events: None,
641
710
  })
642
711
  }
643
712