From 3a4519495d8a477b6f8e7d3c5220fd57fb27a4e6 Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sat, 7 Nov 2026 16:14:26 +0000 Subject: [PATCH 1/4] fix tests broken by final linter rules Fixtures used single-digit G-codes and G01 before M03, which rules 3-5 now reject. Use two-digit G00 and update the inline-comment expectation. Co-Authored-By: Claude Sonnet 5.5 --- tests/test_cutjob_state.py | 30 +++++++++++++++--------------- tests/test_sleep_inhibit.py | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/test_cutjob_state.py b/tests/test_cutjob_state.py index fbbb7ea..69816d3 100644 --- a/tests/test_cutjob_state.py +++ b/tests/test_cutjob_state.py @@ -38,7 +38,7 @@ def bulk_write(self, data: bytes) -> int: return len(data) -def _new_job(payload: bytes = b'G0X0\rG0Y0\rG0Z0\r') -> CutJob: +def _new_job(payload: bytes = b'G00 X0.0\rG00 Y0.0\rG00 Z0.0\r') -> CutJob: m = MDX40A(_AckingMockUSB()) return CutJob.from_bytes(m, payload, 'test.nc') @@ -156,17 +156,17 @@ def test_parse_nc_blocks_does_not_validate(): def test_check_nc_accepts_parse_nc_blocks_output_directly(): """check_nc takes the raw bytes output of parse_nc_blocks; no decoding needed at the call site.""" - blocks = parse_nc_blocks(b"(hello)\nG01 X1.0\n(more)\n") + blocks = parse_nc_blocks(b"(hello)\nG00 X1.0\n(more)\n") assert list(check_nc(blocks)) == [] def test_check_nc_clean_file_returns_no_warnings(): - blocks = [b"(hello)\n", b"G01 X1.0\n", b"(more)\n"] + blocks = [b"(hello)\n", b"G00 X1.0\n", b"(more)\n"] assert list(check_nc(blocks)) == [] def test_check_nc_flags_embedded_close_paren(): - blocks = [b"G01 X1.0\n", b"(this (is) bad)\n", b"G01 Y2.0\n"] + blocks = [b"G00 X1.0\n", b"(this (is) bad)\n", b"G00 Y2.0\n"] warnings = check_nc(blocks) assert len(warnings) == 1 w = warnings[0] @@ -182,19 +182,19 @@ def test_check_nc_flags_embedded_open_paren(): def test_check_nc_flags_unclosed_comment(): - blocks = [b"G01 X1.0\n", b"G01 Y2.0\n", b"(forgot to close\n", - b"G01 Z3.0\n"] + blocks = [b"G00 X1.0\n", b"G00 Y2.0\n", b"(forgot to close\n", + b"G00 Z3.0\n"] warnings = check_nc(blocks) assert len(warnings) == 1 assert warnings[0].rule == RULE_PAREN_UNCLOSED assert warnings[0].line == 3 -def test_check_nc_ignores_parens_in_non_comment_lines(): - # A code line that happens to contain '(' or ')' mid-stream is NOT - # subject to the rule -- only lines that *start* with '(' are - # treated as comments by the firmware. - assert list(check_nc([b"G01 X1.0 ( inline ) F100\n"])) == [] +def test_check_nc_paren_rules_ignore_non_comment_lines(): + # A code line that happens to contain '(' or ')' mid-stream is not a + # comment line, so rules 1 and 2 do not apply (rule 5 flags it instead). + warnings = check_nc([b"G00 X1.0 ( inline ) F100\n"]) + assert [w.rule for w in warnings] == [5] def test_check_nc_locates_column_of_first_embedded_paren(): @@ -211,7 +211,7 @@ def test_check_nc_accepts_empty_comment(): def test_check_nc_reports_all_warnings_not_just_first(): """The lint should surface every issue so the user can fix in one pass.""" - blocks = [b"(bad (one))\n", b"G01 X1.0\n", b"(unclosed\n"] + blocks = [b"(bad (one))\n", b"G00 X1.0\n", b"(unclosed\n"] warnings = check_nc(blocks) assert len(warnings) == 2 rules = sorted(w.rule for w in warnings) @@ -231,7 +231,7 @@ def test_from_bytes_rejects_files_with_warnings(): def test_main_clean_file_returns_zero(tmp_path, capsys): p = tmp_path / "clean.nc" - p.write_bytes(b"(ok)\nG01 X1.0\n") + p.write_bytes(b"(ok)\nG00 X1.0\n") rc = check_main([str(p)]) out = capsys.readouterr().out assert rc == 0 @@ -243,7 +243,7 @@ def test_main_reports_warnings_in_three_line_format(tmp_path, capsys): three lines per warning (header, source, caret). capsys captures non-TTY stdout, so ANSI codes are absent.""" p = tmp_path / "bad.nc" - p.write_bytes(b"G01 X1.0\n(bad (paren))\n") + p.write_bytes(b"G00 X1.0\n(bad (paren))\n") rc = check_main([str(p)]) captured = capsys.readouterr() assert rc == 1 @@ -276,7 +276,7 @@ def test_tui_main_bails_on_bad_file_before_usb(tmp_path, monkeypatch, capsys): warnings and exit *before* MdxUSB.discover() is invoked — there's no point handshaking the mill only to abort after reading the file.""" p = tmp_path / "bad.nc" - p.write_bytes(b"G01 X1.0\n(bad (paren))\n") + p.write_bytes(b"G00 X1.0\n(bad (paren))\n") # Sentinel that fails the test if USB discovery is reached. from rollingmill import usb as usb_mod diff --git a/tests/test_sleep_inhibit.py b/tests/test_sleep_inhibit.py index e57e13b..23d7a2c 100644 --- a/tests/test_sleep_inhibit.py +++ b/tests/test_sleep_inhibit.py @@ -159,7 +159,7 @@ def rel(): return counts -def _new_job(payload: bytes = b'G0X0\rG0Y0\rG0Z0\r') -> CutJob: +def _new_job(payload: bytes = b'G00 X0.0\rG00 Y0.0\rG00 Z0.0\r') -> CutJob: return CutJob.from_bytes(MDX40A(_AckingMockUSB()), payload, 'test.nc') From 5e7f44341fdb332a26b114a330e436058c1adb63 Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sat, 7 Nov 2026 20:58:59 +0000 Subject: [PATCH 2/4] add CI workflow running pytest on pull requests and pushes to main Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e811984 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + name: pytest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install package + dev deps + run: | + python -m pip install --upgrade pip setuptools + python -m pip install ".[dev]" + python -m pip list + + - name: Run tests + run: python -m pytest -q From 950a6d0f2ceeb01a30e4493af672a0932da9832b Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sat, 7 Nov 2026 21:01:55 +0000 Subject: [PATCH 3/4] ci: install cairo headers so pycairo (via erbsland-maze) can build Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e811984..a9dea02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,13 @@ jobs: with: python-version: "3.12" + # erbsland-maze depends on pycairo, which has no Linux wheel and builds + # against the system cairo headers. + - name: Install system libraries + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev pkg-config + - name: Install package + dev deps run: | python -m pip install --upgrade pip setuptools From 86434ba42d2298afa509c4c55c4678e9b6c50e1a Mon Sep 17 00:00:00 2001 From: Chris Shucksmith Date: Sat, 7 Nov 2026 21:05:01 +0000 Subject: [PATCH 4/4] make erbsland-maze an optional 'maze' extra; install cairo headers in CI pycairo has no Linux wheel, so a core dependency on erbsland-maze broke pip install on machines without cairo headers. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 9 ++++++++- README.md | 1 + pyproject.toml | 4 +++- rollingmill/maze.py | 29 +++++++++++++++++------------ 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9dea02..d1a6f2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,17 @@ jobs: sudo apt-get update sudo apt-get install -y libcairo2-dev pkg-config + # erbsland-maze (the 'maze' extra) depends on pycairo, which has no Linux + # wheel and builds against the system cairo headers. + - name: Install system libraries + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev pkg-config + - name: Install package + dev deps run: | python -m pip install --upgrade pip setuptools - python -m pip install ".[dev]" + python -m pip install ".[dev,maze]" python -m pip list - name: Run tests diff --git a/README.md b/README.md index ce9a237..bc719f5 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Getting started This library is available from PyPi as `rollingmill`. $ pip install rollingmill + $ pip install "rollingmill[maze]" # adds maze generator (needs cairo headers on Linux) $ rollingmill # launch TUI on real hardware $ rollingmill --file sample-nc/example-doc-part.gcode # stream an NC file diff --git a/pyproject.toml b/pyproject.toml index 290cfdf..4c73ef3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ classifiers = [ dependencies = [ "pyusb>=1.2", "pyhershey>=0.1", - "erbsland-maze>=1.4.8", "setuptools", # pyhershey imports pkg_resources; drop when upstream moves to importlib.metadata ] @@ -44,6 +43,9 @@ dev = [ "ruff>=0.6", "mypy>=1.10", ] +maze = [ + "erbsland-maze>=1.4.8", # imports pycairo, which builds against system cairo on Linux +] [project.scripts] rollingmill = "rollingmill.tui:main" diff --git a/rollingmill/maze.py b/rollingmill/maze.py index 646c77c..60618f3 100644 --- a/rollingmill/maze.py +++ b/rollingmill/maze.py @@ -20,18 +20,23 @@ from pathlib import Path from typing import Iterable -from erbsland_maze import ( - Direction, - Generator, - GeneratorSetup, - Room, - SvgLayout, - SvgSetup, - Wall, -) -from erbsland_maze.line import Line -from erbsland_maze.point import Point -from erbsland_maze.poly_line import PolyLine +try: + from erbsland_maze import ( + Direction, + Generator, + GeneratorSetup, + Room, + SvgLayout, + SvgSetup, + Wall, + ) + from erbsland_maze.line import Line + from erbsland_maze.point import Point + from erbsland_maze.poly_line import PolyLine +except ImportError as exc: # pragma: no cover - exercised only without the extra + raise ImportError( + "rollingmill.maze needs the 'maze' extra: pip install 'rollingmill[maze]'" + ) from exc ENDPOINT_MODES = ("none", "outline", "filled")