From a59c7fb94a70782bee65d11b10191eb408b4f364 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:18:32 +0000 Subject: [PATCH 01/28] Restore Python 3.7 buildability, bump to 0.3.3 0.3.2 cannot be installed on OctoPi 0.18 and older images (Python 3.7): pip fails at "Installing build dependencies" with "Could not find a version that satisfies the requirement setuptools>=77", because the newest setuptools release that still supports Python 3.7 is 68.0.0. Two things in 0.3.2 forced that floor, and both hard-error against setuptools 68: - `license` as a PEP 639 SPDX string plus a top-level `license-files` needs setuptools >=77; older versions reject it with "`project.license` must be valid exactly by one definition". - `[[tool.setuptools.ext-modules]]` only landed in setuptools 74.1; older versions reject it with "`tool.setuptools` must not contain {'ext-modules'} properties". So drop the build floor to setuptools>=61 (first release with full [project] support, and satisfied by 68.0.0 on Python 3.7), move the license back to the pre-PEP-639 table form with license-files under [tool.setuptools], and declare the C extension in a minimal setup.py again. The shim only calls setup(ext_modules=...) -- all metadata stays static in pyproject.toml, and unlike the setup.py removed in c4349f3 it does not need octoprint_setuptools in the build environment. Verified end to end against setuptools 68.0.0 (the Python 3.7 ceiling) and setuptools 83.0.0: the extension compiles, the wheel contains octoprint_translatemodel/_translate*.so with no C++ source, LICENSE and the octoprint.plugin entry point are recorded, and the extension imports after install. Current setuptools only warns that the license spelling is deprecated (removal announced for 2027-Feb-18), noted inline for when the Python floor eventually rises. Fixes #24 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- pyproject.toml | 23 ++++++++++++++++------- setup.py | 13 +++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml index 4f49c42..ff20260 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,21 @@ [build-system] -requires = ["setuptools>=77", "wheel"] +# Keep this floor low enough that Python 3.7 installs (OctoPi 0.18 and older, +# where the newest available setuptools is 68.0.0) can still build the plugin. +# 61 is the first release with full [project] table support. +requires = ["setuptools>=61", "wheel"] build-backend = "setuptools.build_meta" [project] name = "OctoPrint-TranslateModel" -version = "0.3.2" +version = "0.3.3" description = "A plugin that translates models on the build plate for printing one after the other." -license = "AGPL-3.0-or-later" -license-files = ["LICENSE"] +# PEP 639 (`license` as an SPDX string plus a top-level `license-files`) needs +# setuptools >=77, which does not exist for Python 3.7, so stick to the older +# spelling that every supported setuptools understands. Modern setuptools warns +# that this form (and tool.setuptools.license-files below) is deprecated, with +# removal announced for 2027-Feb-18 -- switch to PEP 639 once the Python floor +# rises past 3.8. +license = {text = "AGPL-3.0-or-later"} authors = [ {name = "Will MacCormack", email = "willmaccormack@gmail.com"} ] @@ -23,10 +31,11 @@ translatemodel = "octoprint_translatemodel" [tool.setuptools] include-package-data = true +license-files = ["LICENSE"] [tool.setuptools.packages.find] include = ["octoprint_translatemodel", "octoprint_translatemodel.*"] -[[tool.setuptools.ext-modules]] -name = "octoprint_translatemodel._translate" -sources = ["src/translate.cpp"] +# The C extension is declared in setup.py rather than as +# [[tool.setuptools.ext-modules]] here: that TOML table only landed in +# setuptools 74.1, which is out of reach on Python 3.7. diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..042c0a0 --- /dev/null +++ b/setup.py @@ -0,0 +1,13 @@ +# All static metadata lives in pyproject.toml. This shim exists only to declare +# the C extension, because the declarative [[tool.setuptools.ext-modules]] table +# requires setuptools >=74.1 and Python 3.7 installs top out at setuptools 68. +from setuptools import Extension, setup + +setup( + ext_modules=[ + Extension( + "octoprint_translatemodel._translate", + sources=["src/translate.cpp"], + ) + ] +) From a10d1fcfd20ff783c850365f4265c5a005bf0e5c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:40:29 +0000 Subject: [PATCH 02/28] Add CI that builds the plugin on every supported Python Nothing has ever built this repo outside a maintainer's machine, which is how 0.3.2 shipped a build-system requirement that no supported Python could satisfy. This adds a workflow that performs an isolated PEP 517 build of the checked-out tree -- the same thing OctoPrint's software update plugin does when it pip-installs archive/.zip -- across the full requires-python range, then installs the wheel and checks it. The matrix covers 3.8 through 3.13 via setup-python. 3.7 gets its own job in the python:3.7-bullseye image, because setup-python's manifest has no 3.7 build for ubuntu-24.04 (it stops at 22.04); bullseye rather than buster so actions/checkout's Node 20 has glibc >= 2.28. Builds install with --no-deps, so each job takes seconds and never resolves OctoPrint's dependency tree. That loses nothing: issue #24 failed while installing *build* dependencies, long before any runtime dependency was considered. check_build.py asserts the things this repo has actually broken before: the compiled extension present and still named _translate inside the package, no C++ source in the wheel, templates and static assets packaged, LICENSE recorded, and the octoprint.plugin entry point pointing at octoprint_translatemodel. It then loads the installed extension by file path and calls it -- by file rather than by import, since importing the parent package would need OctoPrint. Verified by running the workflow's step sequence locally against setuptools 68.0.0 (the 3.7 ceiling) and 83.0.0: both pass. Also verified the checks bite -- a wheel with the extension renamed back to `translate` and entry_points.txt removed exits 1 naming both faults. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/scripts/check_build.py | 191 +++++++++++++++++++++++++++++++++ .github/workflows/build.yml | 67 ++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 .github/scripts/check_build.py create mode 100644 .github/workflows/build.yml diff --git a/.github/scripts/check_build.py b/.github/scripts/check_build.py new file mode 100644 index 0000000..f8653ad --- /dev/null +++ b/.github/scripts/check_build.py @@ -0,0 +1,191 @@ +"""Structural and import checks for a built OctoPrint-TranslateModel wheel. + +Usage, after `pip install --no-deps `: + + python .github/scripts/check_build.py dist/OctoPrint_TranslateModel-*.whl + +Every assertion here corresponds to something that has actually gone wrong in +this repo's packaging: the C extension going missing or being renamed, the C++ +source leaking into the wheel, the octoprint.plugin entry point disappearing +when the build config was rewritten, and LICENSE dropping out of the metadata. + +Kept syntax-compatible with Python 3.7, since that is the oldest interpreter +OctoPrint (and therefore this plugin) still supports. +""" + +import configparser +import importlib.util +import os +import sys +import zipfile + +PACKAGE = "octoprint_translatemodel" +EXT_MODULE = "_translate" +ENTRY_POINT_GROUP = "octoprint.plugin" +ENTRY_POINT_NAME = "translatemodel" + +failures = [] + + +def check(condition, message): + if condition: + print(" ok {}".format(message)) + else: + print(" FAIL {}".format(message)) + failures.append(message) + + +def dist_info_dir(names): + for name in names: + head = name.split("/")[0] + if head.endswith(".dist-info"): + return head + return None + + +def check_wheel(path): + print("wheel: {}".format(os.path.basename(path))) + with zipfile.ZipFile(path) as wheel: + names = wheel.namelist() + + # The compiled extension has to land inside the package. It was a + # top-level `translate` module until 0.3.2 nested it to avoid colliding + # with the unrelated `translate` package on PyPI. + ext_suffixes = (".so", ".pyd") + exts = [ + n + for n in names + if n.startswith(PACKAGE + "/") and n.endswith(ext_suffixes) + ] + check( + len(exts) == 1, + "exactly one compiled extension in {}/ (found {})".format( + PACKAGE, exts or "none" + ), + ) + if exts: + check( + os.path.basename(exts[0]).startswith(EXT_MODULE + "."), + "extension is named {} (found {})".format( + EXT_MODULE, os.path.basename(exts[0]) + ), + ) + + check( + not [n for n in names if n.endswith((".cpp", ".c", ".h"))], + "no C/C++ sources shipped in the wheel", + ) + + for expected in ( + PACKAGE + "/__init__.py", + PACKAGE + "/static/js/translatemodel.js", + PACKAGE + "/templates/translatemodel_settings.jinja2", + ): + check(expected in names, "{} is packaged".format(expected)) + + info = dist_info_dir(names) + check(info is not None, "wheel contains a .dist-info directory") + if info is None: + return + + # setuptools >=77 writes license files to .dist-info/licenses/, older + # versions put them directly in .dist-info/ -- accept either. + check( + any( + n.startswith(info + "/") + and os.path.basename(n) == "LICENSE" + for n in names + ), + "LICENSE recorded in {}".format(info), + ) + + ep_path = info + "/entry_points.txt" + check(ep_path in names, "entry_points.txt recorded") + if ep_path in names: + parser = configparser.ConfigParser() + parser.read_string(wheel.read(ep_path).decode("utf-8")) + declared = ( + parser[ENTRY_POINT_GROUP].get(ENTRY_POINT_NAME) + if parser.has_section(ENTRY_POINT_GROUP) + else None + ) + check( + declared == PACKAGE, + "{} entry point {} -> {} (found {})".format( + ENTRY_POINT_GROUP, ENTRY_POINT_NAME, PACKAGE, declared + ), + ) + + metadata = wheel.read(info + "/METADATA").decode("utf-8") + headers = {} + for line in metadata.splitlines(): + if not line.strip(): + break + if ": " in line: + key, _, value = line.partition(": ") + headers.setdefault(key.lower(), value) + check( + headers.get("requires-python", "") != "", + "Requires-Python is declared (found {!r})".format( + headers.get("requires-python", "") + ), + ) + # License-Expression is the PEP 639 spelling used by setuptools >=77. + check( + bool(headers.get("license") or headers.get("license-expression")), + "license is declared in METADATA", + ) + + +def check_import(): + print("installed extension:") + + # Drop the working directory so an in-tree ./octoprint_translatemodel can + # never be mistaken for the installed package (which is the only one with a + # compiled extension in it). + cwd = os.getcwd() + sys.path[:] = [p for p in sys.path if p not in ("", ".", cwd)] + + spec = importlib.util.find_spec(PACKAGE) + if spec is None or not spec.submodule_search_locations: + check(False, "installed {} package is importable".format(PACKAGE)) + return + location = list(spec.submodule_search_locations)[0] + check(True, "installed at {}".format(location)) + + candidates = [ + os.path.join(location, n) + for n in sorted(os.listdir(location)) + if n.startswith(EXT_MODULE + ".") and n.endswith((".so", ".pyd")) + ] + check(len(candidates) == 1, "one installed {} extension".format(EXT_MODULE)) + if not candidates: + return + + # Loaded by file rather than `import octoprint_translatemodel._translate`, + # because importing the parent package pulls in OctoPrint and these jobs + # deliberately install with --no-deps. + name = "{}.{}".format(PACKAGE, EXT_MODULE) + ext_spec = importlib.util.spec_from_file_location(name, candidates[0]) + module = importlib.util.module_from_spec(ext_spec) + ext_spec.loader.exec_module(module) + check(callable(getattr(module, "translate", None)), "translate() is callable") + + +def main(argv): + if len(argv) != 1: + print("usage: check_build.py ", file=sys.stderr) + return 2 + + check_wheel(argv[0]) + check_import() + + if failures: + print("\n{} check(s) failed".format(len(failures)), file=sys.stderr) + return 1 + print("\nall checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..eb78350 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,67 @@ +name: build + +on: + push: + branches: [main, "claude/**"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: py${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + # An isolated PEP 517 build of the checked-out tree, which is what + # OctoPrint's software update plugin does when it pip-installs + # archive/.zip. pip resolves build-system.requires against *this* + # interpreter, so old Pythons get the newest setuptools they can have -- + # that resolution is what failed on 0.3.2 (issue #24). + - name: Build wheel + run: pip wheel --no-deps --wheel-dir dist . + + # --no-deps keeps this to seconds and skips OctoPrint's dependency tree; + # the checks below never need OctoPrint importable. + - name: Install wheel + run: pip install --no-deps dist/*.whl + + - name: Check wheel contents and extension + run: python .github/scripts/check_build.py dist/*.whl + + build-py37: + name: py3.7 (container) + runs-on: ubuntu-latest + # setup-python has no 3.7 build for ubuntu-24.04 (its manifest stops at + # 22.04), so 3.7 is tested in the official image instead. bullseye rather + # than buster: actions/checkout runs on Node 20, which needs glibc >= 2.28. + # + # Any pip works here -- there is no setuptools >=77 candidate for 3.7 at + # all, so the 0.3.2 failure reproduces regardless of resolver version. + container: python:3.7-bullseye + steps: + - uses: actions/checkout@v4 + + - name: Build wheel + run: pip wheel --no-deps --wheel-dir dist . + + - name: Install wheel + run: pip install --no-deps dist/*.whl + + - name: Check wheel contents and extension + run: python .github/scripts/check_build.py dist/*.whl From 559135e5bf8c4a13ac812897ac6d3710ec397db8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:43:56 +0000 Subject: [PATCH 03/28] Run CI once per commit, not twice The first run showed the workflow firing twice for the same SHA on a branch with an open PR: once for push, once for pull_request. Limit the push trigger to main so branch commits are covered by the PR event alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/workflows/build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eb78350..5ad4c95 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,10 @@ name: build on: + # Branch pushes are covered by the pull_request event; listing them here as + # well would run every commit on an open PR twice. push: - branches: [main, "claude/**"] + branches: [main] pull_request: workflow_dispatch: From 1e2f0ef4d493b043de49b294b044fe5737e1e3d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 13:09:33 +0000 Subject: [PATCH 04/28] Add a CI job that loads the plugin against real OctoPrint The build jobs deliberately install with --no-deps, which means they never prove OctoPrint can actually find the plugin. This job installs the wheel with its dependencies and walks the same path OctoPrint's plugin manager takes at startup: resolve the octoprint.plugin entry point, import the module behind it, call __plugin_load__(), and inspect the result -- no server boot required. It covers what check_build.py structurally cannot: an entry point that resolves to nothing importable, a compiled extension that fails to load through the normal `from . import _translate` path rather than by file, and a plugin class that stops registering as the mixins OctoPrint dispatches on. It also pins the installed version to pyproject's, so a half-finished release bump gets caught. Pinned to 3.11, the version OctoPi's bookworm image ships. Verified locally against OctoPrint 1.11.8 on 3.11: all 18 checks pass, install included takes about 20s. Both failure modes were confirmed to fail cleanly -- stripping entry_points.txt from the installed dist-info and removing the .so each exit 1 with a named FAIL rather than a traceback. Note for anyone extending these scripts: `pip wheel .` leaves an egg-info directory in the checkout, so metadata lookups run from the repo root can find that instead of the installed distribution. Both scripts drop the working directory from sys.path to avoid it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/scripts/check_plugin_load.py | 141 +++++++++++++++++++++++++++ .github/workflows/build.yml | 24 +++++ 2 files changed, 165 insertions(+) create mode 100644 .github/scripts/check_plugin_load.py diff --git a/.github/scripts/check_plugin_load.py b/.github/scripts/check_plugin_load.py new file mode 100644 index 0000000..08d9ab1 --- /dev/null +++ b/.github/scripts/check_plugin_load.py @@ -0,0 +1,141 @@ +"""Assert that OctoPrint can discover and load the installed plugin. + +Usage, after `pip install ` (with dependencies, so OctoPrint is present): + + python .github/scripts/check_plugin_load.py + +This follows the same path OctoPrint's plugin manager takes at startup -- +resolve the octoprint.plugin entry point, import the module behind it, call +__plugin_load__(), and inspect what comes out -- without booting a server. It +catches the failures check_build.py cannot see: a wheel whose entry point +resolves to nothing importable, an extension that will not load through the +normal `from . import _translate` path, and a plugin class that no longer +registers as the mixins OctoPrint dispatches on. +""" + +import importlib.metadata as md +import os +import sys +import tomllib + +DIST = "OctoPrint-TranslateModel" +PACKAGE = "octoprint_translatemodel" +ENTRY_POINT_GROUP = "octoprint.plugin" +ENTRY_POINT_NAME = "translatemodel" +HOOK = "octoprint.plugin.softwareupdate.check_config" + +failures = [] + + +def check(condition, message): + if condition: + print(" ok {}".format(message)) + else: + print(" FAIL {}".format(message)) + failures.append(message) + + +def find_entry_point(): + points = md.entry_points() + # entry_points() grew a select()/group= API in 3.10; older versions return a + # dict keyed by group. + if hasattr(points, "select"): + group = list(points.select(group=ENTRY_POINT_GROUP)) + else: + group = list(points.get(ENTRY_POINT_GROUP, [])) + for point in group: + if point.name == ENTRY_POINT_NAME: + return point + return None + + +def main(): + # Keep an in-tree ./octoprint_translatemodel from shadowing the installed + # one, which is the only copy with a compiled extension beside it. + cwd = os.getcwd() + sys.path[:] = [p for p in sys.path if p not in ("", ".", cwd)] + + print("installed distribution:") + try: + installed = md.version(DIST) + except md.PackageNotFoundError: + installed = None + check(installed is not None, "{} is installed (found {})".format(DIST, installed)) + if installed is None: + return 1 + + with open(os.path.join(cwd, "pyproject.toml"), "rb") as handle: + declared = tomllib.load(handle)["project"]["version"] + check( + installed == declared, + "installed version matches pyproject ({} vs {})".format(installed, declared), + ) + + print("entry point:") + point = find_entry_point() + check(point is not None, "{} declares {}".format(ENTRY_POINT_GROUP, ENTRY_POINT_NAME)) + if point is None: + return 1 + check( + point.value == PACKAGE, + "{} points at {} (found {})".format(ENTRY_POINT_NAME, PACKAGE, point.value), + ) + + try: + plugin = point.load() + except Exception as error: + # Most likely the compiled extension is missing or unloadable, since + # __init__.py imports it at module scope. + check(False, "entry point imports {} ({}: {})".format(PACKAGE, type(error).__name__, error)) + return 1 + check(getattr(plugin, "__name__", None) == PACKAGE, "entry point imports {}".format(PACKAGE)) + check(bool(getattr(plugin, "__plugin_name__", "")), "__plugin_name__ is set") + check( + bool(getattr(plugin, "__plugin_pythoncompat__", "")), + "__plugin_pythoncompat__ is set", + ) + + print("compiled extension, via the plugin's own import:") + extension = getattr(plugin, "translate", None) + check(extension is not None, "module exposes the extension") + if extension is not None: + for name in ("translate", "test"): + check( + callable(getattr(extension, name, None)), + "extension provides {}()".format(name), + ) + + print("plugin load:") + plugin.__plugin_load__() + implementation = getattr(plugin, "__plugin_implementation__", None) + check(implementation is not None, "__plugin_load__() set an implementation") + if implementation is None: + return 1 + + import octoprint.plugin + + for mixin in ( + "SettingsPlugin", + "AssetPlugin", + "TemplatePlugin", + "SimpleApiPlugin", + "StartupPlugin", + "EventHandlerPlugin", + ): + check( + isinstance(implementation, getattr(octoprint.plugin, mixin)), + "implementation registers as {}".format(mixin), + ) + + hooks = getattr(plugin, "__plugin_hooks__", {}) + check(callable(hooks.get(HOOK)), "{} hook is registered".format(HOOK)) + + if failures: + print("\n{} check(s) failed".format(len(failures)), file=sys.stderr) + return 1 + print("\nall checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ad4c95..379d1a6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,6 +46,30 @@ jobs: - name: Check wheel contents and extension run: python .github/scripts/check_build.py dist/*.whl + plugin-loads: + name: OctoPrint plugin discovery + runs-on: ubuntu-latest + # 3.11 is what OctoPi's bookworm image ships, so it is the most + # representative single version to test discovery on. (It is also the floor + # for the tomllib the check script uses to read pyproject.toml.) + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Build wheel + run: pip wheel --no-deps --wheel-dir dist . + + # Unlike the build jobs, this one installs dependencies: the point is to + # load the plugin against a real OctoPrint. + - name: Install wheel with OctoPrint + run: pip install dist/*.whl + + - name: Check OctoPrint discovers and loads the plugin + run: python .github/scripts/check_plugin_load.py + build-py37: name: py3.7 (container) runs-on: ubuntu-latest From a892682a6523c6ebdc7e02fa6d9cbf4e3404d9e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 13:12:56 +0000 Subject: [PATCH 05/28] Also check plugin discovery against the OctoPrint 2.0 prerelease OctoPrint 2.0 has been in rc since April 2026 and is the release that raises OctoPrint's own Python floor to 3.9, so it is worth knowing now whether the plugin still loads under it rather than finding out when users upgrade. The discovery job becomes a matrix over OctoPrint version. The 2.0 leg installs the plugin normally -- which resolves stable OctoPrint -- and then upgrades in place, because that is the order real users hit it: the plugin is already installed when OctoPrint moves to the new major. Both legs run on 3.11, which OctoPi's bookworm image ships and which both 1.11 and 2.0 support. Each leg now also prints the OctoPrint version it resolved, so a failure log says what it was actually testing. Verified locally: the plugin loads under 2.0.0rc4 with all 18 checks passing and no API drift -- every mixin still registers and the softwareupdate hook is intact. This leg is a hard gate rather than continue-on-error, so a regression in a future rc is visible instead of silently green. The trade-off is that a broken rc can turn the branch red for reasons outside this repo; if that becomes a nuisance, adding continue-on-error to the 2.0 leg is the one-line fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/workflows/build.yml | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 379d1a6..4eb82ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,11 +47,19 @@ jobs: run: python .github/scripts/check_build.py dist/*.whl plugin-loads: - name: OctoPrint plugin discovery + name: OctoPrint plugin discovery (${{ matrix.octoprint }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # stable is whatever `pip install OctoPrint` gives a user today; 2.0 is + # the next major, in rc since April 2026 and the release that raises + # OctoPrint's own Python floor to 3.9. + octoprint: [stable, "2.0-prerelease"] # 3.11 is what OctoPi's bookworm image ships, so it is the most - # representative single version to test discovery on. (It is also the floor - # for the tomllib the check script uses to read pyproject.toml.) + # representative single version to test discovery on, and it is supported by + # both OctoPrint 1.11 and 2.0. (It is also the floor for the tomllib the + # check script uses to read pyproject.toml.) steps: - uses: actions/checkout@v4 @@ -67,6 +75,16 @@ jobs: - name: Install wheel with OctoPrint run: pip install dist/*.whl + # Upgrading after the fact rather than resolving 2.0 up front, because + # that is the order real users hit it: plugin already installed, then + # OctoPrint moves to the new major. + - name: Upgrade to the OctoPrint 2.0 prerelease + if: matrix.octoprint == '2.0-prerelease' + run: pip install --upgrade --pre "OctoPrint>=2.0.0rc1,<3" + + - name: Report OctoPrint version + run: python -c "import octoprint; print(octoprint.__version__)" + - name: Check OctoPrint discovers and loads the plugin run: python .github/scripts/check_plugin_load.py From 070580fb129d2953db31f064e82f9819358926e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 13:59:32 +0000 Subject: [PATCH 06/28] Bump checkout and setup-python to v7 The first CI runs warned that checkout@v4 and setup-python@v5 target Node 20 and were being force-run on Node 24. v7 is the current major for both (checkout v7.0.1, setup-python v7.0.0); both are ESM on Node 24 and want runner >= 2.327.1, which GitHub-hosted runners are well past. The one breaking change in checkout v7 is that fork PRs are no longer checked out for pull_request_target and workflow_run without opting in via allow-unsafe-pr-checkout. This workflow triggers on push, pull_request and workflow_dispatch, so it is unaffected -- and the new default is the safer one regardless. Node 24 in the 3.7 container was already proven by the previous run, where the runner force-ran the older actions on it and the job passed: bullseye ships glibc 2.31 against Node 24's 2.28 floor. Comment updated to say so, since it previously cited Node 20. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/workflows/build.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4eb82ea..10a51a2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,9 +24,9 @@ jobs: matrix: python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: ${{ matrix.python }} @@ -61,9 +61,9 @@ jobs: # both OctoPrint 1.11 and 2.0. (It is also the floor for the tomllib the # check script uses to read pyproject.toml.) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.11" @@ -93,13 +93,15 @@ jobs: runs-on: ubuntu-latest # setup-python has no 3.7 build for ubuntu-24.04 (its manifest stops at # 22.04), so 3.7 is tested in the official image instead. bullseye rather - # than buster: actions/checkout runs on Node 20, which needs glibc >= 2.28. + # than buster: the runner injects its own Node into the container to run + # actions/checkout (Node 24 as of v7), which needs glibc >= 2.28. Bullseye + # has 2.31; buster sits right on the boundary. # # Any pip works here -- there is no setuptools >=77 candidate for 3.7 at # all, so the 0.3.2 failure reproduces regardless of resolver version. container: python:3.7-bullseye steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Build wheel run: pip wheel --no-deps --wheel-dir dist . From 31700e847cc3f8c9cf3eca32f6625d2da855cbc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:36:13 +0000 Subject: [PATCH 07/28] Test the translation logic, probe 3.14, add Dependabot Until now CI proved the plugin builds, installs and loads, but nothing checked the thing it exists to do. src/translate.cpp had no coverage at all, and a regression there does not crash -- it silently emits wrong coordinates and ruins a print. tests/ drives the extension directly, no OctoPrint required: the wheel is already installed by the build jobs, and conftest loads the compiled module by file path so importing the parent package (which needs OctoPrint) never happens. 20 tests cover single and multi-shift output, relative-mode moves being left alone, Z/E/F passing through untouched, G0-G3 versus G4, rounding, CRLF preservation, output file naming, the version header, layer markers, and preview mode returning gcode rather than a path. Two of those deserve calling out, because writing the tests is what surfaced them: translation only begins at the first layer-start match and ends at the stop match, so start and end gcode are copied through unshifted. That is correct and load-bearing -- shifting the priming line would send it off the bed -- and it now has explicit tests rather than being implicit in a regex. The tests run inside the existing build jobs rather than a new one, so they execute on every interpreter from 3.7 to 3.13; the extension is compiled per-Python, so per-version coverage is worth having. On 3.7 pip resolves pytest 7.4.4, the last release supporting it. Verified the suite has teeth rather than just passing: swapping shift[0] for shift[1] in the X branch of translateLine and rebuilding fails four tests. Also adds a 3.14 probe and Dependabot for actions. The probe forces the build past the requires-python cap to answer whether <3.14 can be lifted -- 3.14 shipped in October 2025 and OctoPrint 2.0 allows <3.15, so the cap will start excluding people. It is continue-on-error because a failure is a version this package already declares unsupported, unlike the OctoPrint 2.0 leg which gates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/dependabot.yml | 13 +++ .github/workflows/build.yml | 48 +++++++++ tests/conftest.py | 93 +++++++++++++++++ tests/test_translate.py | 202 ++++++++++++++++++++++++++++++++++++ 4 files changed, 356 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 tests/conftest.py create mode 100644 tests/test_translate.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..593e6bd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + # checkout and setup-python sat three majors behind before anyone noticed; + # this is the cheap way to not repeat that. Monthly and grouped, so a small + # repo gets one PR rather than a trickle. + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 10a51a2..8e2f35d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,6 +46,14 @@ jobs: - name: Check wheel contents and extension run: python .github/scripts/check_build.py dist/*.whl + # The behavioural half: does it actually translate gcode correctly on + # this interpreter. Runs on every version because the extension is + # compiled per-Python. + - name: Run the translate tests + run: | + pip install pytest + python -m pytest tests -q + plugin-loads: name: OctoPrint plugin discovery (${{ matrix.octoprint }}) runs-on: ubuntu-latest @@ -111,3 +119,43 @@ jobs: - name: Check wheel contents and extension run: python .github/scripts/check_build.py dist/*.whl + + # pip resolves pytest 7.4.4 here, the last release supporting 3.7. + - name: Run the translate tests + run: | + pip install pytest + python -m pytest tests -q + + probe-py314: + name: py3.14 (probe) + runs-on: ubuntu-latest + # requires-python caps at <3.14, so pip refuses 3.14 outright; this forces + # the build to answer whether that cap can be lifted. 3.14 shipped in + # October 2025 and OctoPrint 2.0 allows up to <3.15, so the cap will start + # excluding people. The thing most likely to break is + # PyImport_ImportModuleNoBlock in translate.cpp, deprecated since 3.13. + # + # Informational, hence continue-on-error: a failure here is a version this + # package already declares unsupported, not a regression, so unlike the + # OctoPrint 2.0 leg it must not gate the branch. + continue-on-error: true + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.14" + + - name: Build wheel + run: pip wheel --no-deps --ignore-requires-python --wheel-dir dist . + + - name: Install wheel + run: pip install --no-deps --ignore-requires-python dist/*.whl + + - name: Check wheel contents and extension + run: python .github/scripts/check_build.py dist/*.whl + + - name: Run the translate tests + run: | + pip install pytest + python -m pytest tests -q diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a58ac99 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,93 @@ +"""Test fixtures for the compiled translate extension. + +The extension is loaded from the *installed* package by file path rather than +with a plain import, because importing octoprint_translatemodel/__init__.py +pulls in OctoPrint, which these tests do not need and CI does not install. +""" + +import importlib.util +import os +import sys + +import pytest + +PACKAGE = "octoprint_translatemodel" +EXT_MODULE = "_translate" + +# Defaults straight out of the plugin's get_settings_defaults(), so the tests +# exercise the patterns real users actually run with. +LAYER_START_REGEX = ( + r"^;(( BEGIN_|BEFORE_)*LAYER_(CHANGE|OBJECT)|LAYER:[0-9]+|" + r" [<]{0,1}layer [0-9]+[>,]{0,1}).*$" +) +STOP_REGEX = r"(end|disable|^; Filament-specific end gcode$)" + +VERSION = "0.0.0-test" + + +def _load_extension(): + # Keep an in-tree ./octoprint_translatemodel (no compiled extension beside + # it) from shadowing the installed package. + cwd = os.getcwd() + sys.path[:] = [p for p in sys.path if p not in ("", ".", cwd)] + + spec = importlib.util.find_spec(PACKAGE) + if spec is None or not spec.submodule_search_locations: + raise RuntimeError( + "{} is not installed; run `pip install --no-deps dist/*.whl` first".format( + PACKAGE + ) + ) + + location = list(spec.submodule_search_locations)[0] + candidates = [ + os.path.join(location, name) + for name in sorted(os.listdir(location)) + if name.startswith(EXT_MODULE + ".") and name.endswith((".so", ".pyd")) + ] + if not candidates: + raise RuntimeError("no compiled {} extension in {}".format(EXT_MODULE, location)) + + name = "{}.{}".format(PACKAGE, EXT_MODULE) + ext_spec = importlib.util.spec_from_file_location(name, candidates[0]) + module = importlib.util.module_from_spec(ext_spec) + ext_spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="session") +def translate(): + return _load_extension() + + +@pytest.fixture +def gcode(tmp_path): + """Write gcode to a file and return its path. + + Takes a list of lines, or a raw string when a test cares about line + endings. + """ + + def write(content, name="model.gcode"): + if not isinstance(content, str): + content = "\n".join(content) + "\n" + path = tmp_path / name + path.write_bytes(content.encode()) + return str(path) + + return write + + +@pytest.fixture +def run(translate, gcode): + """Translate gcode and return the output lines.""" + + def go(content, shifts, name="model.gcode"): + in_path = gcode(content, name) + out_path = translate.translate( + shifts, in_path, (LAYER_START_REGEX, STOP_REGEX), VERSION + ) + with open(out_path) as handle: + return out_path, handle.read().splitlines() + + return go diff --git a/tests/test_translate.py b/tests/test_translate.py new file mode 100644 index 0000000..d637a66 --- /dev/null +++ b/tests/test_translate.py @@ -0,0 +1,202 @@ +"""Behavioural tests for the translate extension. + +These cover what the packaging checks cannot: that a gcode file actually comes +out the far side with the coordinates moved where they should be. + +Note the shape of the fixtures: translation only begins once a line matches the +layer-start regex, and stops again at the stop regex. Everything outside that +window -- start gcode, end gcode -- is copied through untouched, which is why +almost every fixture here opens with a ;LAYER:0 marker. +""" + +import os + +import pytest + +from conftest import LAYER_START_REGEX, STOP_REGEX, VERSION + +LAYER = ";LAYER:0" +END = "; Filament-specific end gcode" + + +def coords(lines, prefix="G1"): + """Pull the X/Y/... arguments off every line whose command is `prefix`.""" + found = [] + for line in lines: + tokens = line.split(";")[0].split() + if not tokens or tokens[0] != prefix: + continue + args = {} + for token in tokens[1:]: + if token[:1].isalpha() and len(token) > 1: + args[token[0].upper()] = token[1:] + found.append(args) + return found + + +class TestTranslationWindow: + """Only gcode between the layer-start and stop markers gets moved.""" + + def test_preamble_is_not_shifted(self, run): + # Start gcode -- priming line, bed levelling -- must stay put, or the + # printer would prime off the bed. + _, lines = run( + ["G90", "G1 X0 Y0 E10", LAYER, "G1 X0 Y0"], + [(50.0, 50.0)], + ) + assert coords(lines) == [ + {"X": "0", "Y": "0", "E": "10"}, + {"X": "50", "Y": "50"}, + ] + + def test_end_gcode_is_not_shifted(self, run): + _, lines = run( + ["G90", LAYER, "G1 X1 Y1", END, "G1 X0 Y0"], + [(50.0, 50.0)], + ) + assert coords(lines) == [ + {"X": "51", "Y": "51"}, + {"X": "0", "Y": "0"}, + ] + + +class TestSingleShift: + def test_absolute_moves_are_shifted(self, run): + _, lines = run( + ["G90", LAYER, "G1 X10 Y20 E5", "G1 X0 Y0"], + [(5.0, 2.5)], + ) + assert coords(lines) == [ + {"X": "15", "Y": "22.5", "E": "5"}, + {"X": "5", "Y": "2.5"}, + ] + + def test_negative_shift(self, run): + _, lines = run(["G90", LAYER, "G1 X10 Y10"], [(-2.5, -10.0)]) + assert coords(lines) == [{"X": "7.5", "Y": "0"}] + + def test_relative_moves_are_left_alone(self, run): + # After G91 the coordinates are deltas, so shifting them would move the + # model again on every single move. + _, lines = run( + ["G90", LAYER, "G91", "G1 X10 Y20", "G90", "G1 X10 Y20"], + [(5.0, 5.0)], + ) + assert coords(lines) == [ + {"X": "10", "Y": "20"}, + {"X": "15", "Y": "25"}, + ] + + def test_z_and_extrusion_are_untouched(self, run): + _, lines = run(["G90", LAYER, "G1 X1 Y1 Z0.3 E12.5 F1800"], [(1.0, 1.0)]) + assert coords(lines) == [ + {"X": "2", "Y": "2", "Z": "0.3", "E": "12.5", "F": "1800"} + ] + + def test_g0_travel_moves_are_shifted(self, run): + _, lines = run(["G90", LAYER, "G0 X10 Y10"], [(5.0, 5.0)]) + assert coords(lines, prefix="G0") == [{"X": "15", "Y": "15"}] + + def test_non_motion_commands_pass_through(self, run): + _, lines = run( + ["G90", LAYER, "M104 S200", "T0", "G1 X1 Y1"], + [(1.0, 1.0)], + ) + assert "M104 S200" in lines + assert "T0" in lines + + def test_g4_dwell_is_not_treated_as_a_move(self, run): + # Only G0-G3 are moves; G4 P100 must not have its arguments rewritten. + _, lines = run(["G90", LAYER, "G4 P100"], [(5.0, 5.0)]) + assert "G4 P100" in lines + + def test_fractional_shift_is_rounded_to_three_places(self, run): + _, lines = run(["G90", LAYER, "G1 X1.0005 Y2"], [(0.001, 0.0)]) + x = coords(lines)[0]["X"] + assert len(x.split(".")[1]) <= 3 + + def test_header_records_the_version(self, run): + _, lines = run(["G90", LAYER, "G1 X1 Y1"], [(0.0, 0.0)]) + assert lines[0] == "; Processed by OctoPrint-TranslateModel " + VERSION + + def test_output_path_is_derived_from_the_input(self, run): + out_path, _ = run(["G90", LAYER, "G1 X1 Y1"], [(0.0, 0.0)], name="cube.gcode") + assert os.path.basename(out_path) == "cube.translate_1_shifts.gcode" + assert os.path.exists(out_path) + + +class TestMultipleShifts: + def test_layer_is_emitted_once_per_shift(self, run): + _, lines = run( + ["G90", LAYER, "G1 X10 Y10", ";LAYER:1", "G1 X20 Y20"], + [(0.0, 0.0), (100.0, 0.0)], + ) + # Each layer's moves come out once per shift, layer by layer, so the + # copies print together rather than one whole model at a time. + assert coords(lines) == [ + {"X": "10", "Y": "10"}, + {"X": "110", "Y": "10"}, + {"X": "20", "Y": "20"}, + {"X": "120", "Y": "20"}, + ] + + def test_layer_markers_are_inserted(self, run): + _, lines = run( + ["G90", LAYER, "G1 X1 Y1"], + [(0.0, 0.0), (10.0, 10.0)], + ) + assert ";TRANSLATE-MODEL_LAYER_START" in lines + + def test_stop_regex_ends_translation(self, run): + _, lines = run( + ["G90", LAYER, "G1 X1 Y1", END, "G1 X9 Y9"], + [(0.0, 0.0), (10.0, 10.0)], + ) + assert ";TRANSLATE-MODEL_STOP" in lines + # The move after the stop marker is emitted once, not once per shift. + assert coords(lines).count({"X": "9", "Y": "9"}) == 1 + + def test_shift_count_is_in_the_output_name(self, run): + out_path, _ = run( + ["G90", LAYER, "G1 X1 Y1"], + [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)], + name="part.gcode", + ) + assert os.path.basename(out_path) == "part.translate_3_shifts.gcode" + + +class TestLineEndings: + def test_crlf_input_produces_crlf_output(self, translate, gcode): + in_path = gcode("G90\r\n" + LAYER + "\r\nG1 X10 Y10\r\n", "crlf.gcode") + out_path = translate.translate( + [(5.0, 5.0)], in_path, (LAYER_START_REGEX, STOP_REGEX), VERSION + ) + with open(out_path, "rb") as handle: + body = handle.read() + assert b"G1 X15 Y15\r\n" in body + + def test_lf_input_stays_lf(self, translate, gcode): + in_path = gcode("G90\n" + LAYER + "\nG1 X10 Y10\n", "lf.gcode") + out_path = translate.translate( + [(5.0, 5.0)], in_path, (LAYER_START_REGEX, STOP_REGEX), VERSION + ) + with open(out_path, "rb") as handle: + body = handle.read() + assert b"\r\n" not in body + assert b"G1 X15 Y15\n" in body + + +class TestPreview: + def test_preview_returns_gcode_instead_of_a_path(self, translate, gcode): + in_path = gcode(["G90", LAYER, "G1 X10 Y10"], "preview.gcode") + result = translate.translate( + [(5.0, 5.0)], in_path, (LAYER_START_REGEX, STOP_REGEX), VERSION, True + ) + assert not os.path.exists(result) + assert "G1 X15 Y15" in result + + +class TestArguments: + def test_rejects_a_bad_signature(self, translate): + with pytest.raises(TypeError): + translate.translate([(0.0, 0.0)], "nope.gcode") From 762b041c57e65dd27c2a426fbe093dcea21fb470 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 12:58:14 +0000 Subject: [PATCH 08/28] Stop bad input from crashing the extension Probing the extension with malformed input found three ways to kill the interpreter outright, all in translate_translate's argument handling: - An invalid layer-start or stop pattern threw std::regex_error out of translate() and into CPython, where an escaping C++ exception calls std::terminate (SIGABRT). This is the reachable one: those patterns come straight from plugin settings, so a typo in a settings field took the whole OctoPrint process down mid-print. - A shift with fewer than two entries read past the end of the sequence (SIGSEGV), as did a non-numeric coordinate, because PyNumber_Float's NULL return was passed straight to PyFloat_AS_DOUBLE. - A long enough shift list overflowed the stack (SIGSEGV): shifts was a variable-length array sized directly by the caller. One million shifts is 16MB against an 8MB stack. So: catch std::exception around translate() and raise ValueError with the message instead, validate each shift's length and coordinate conversion, and move the shift array to a std::vector. An empty shift list is now rejected too -- translate() reads shifts[0] on the single-shift path, so it was reading off the end of a zero-length array. The malformed-shift paths were not reachable through the plugin, which coerces with float(shift[0]), float(shift[1]) and would raise in Python first; they were still a segfault in the extension's public API. The regex path had no such guard. Also drops a stray Py_INCREF on the borrowed shift list argument, which leaked a reference on every call, and the reference leaks in the shift parsing loop that the rewrite subsumes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- src/translate.cpp | 92 +++++++++++++++++++++++++++++++++-------- tests/test_bad_input.py | 90 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 tests/test_bad_input.py diff --git a/src/translate.cpp b/src/translate.cpp index fff08ad..8ca14ed 100644 --- a/src/translate.cpp +++ b/src/translate.cpp @@ -3,10 +3,12 @@ #include +#include #include #include #include #include +#include // Logging Vars static char module_name[] = "octoprint.plugins.translatemodel-C++"; @@ -504,7 +506,6 @@ translate_translate(PyObject *self, PyObject *args) &sr, &er, &ver, &preview)) return NULL; - Py_INCREF(shiftList); logging_message = Py_BuildValue("s", "After tuple parse"); Py_XINCREF(logging_message); @@ -512,42 +513,97 @@ translate_translate(PyObject *self, PyObject *args) Py_DECREF(logging_message); // parse through all the shifts - shiftList = PySequence_Fast(shiftList, "argument must be iterable"); - if(!shiftList) - return 0; + PyObject *fastShiftList = PySequence_Fast(shiftList, "shifts must be a sequence"); + if (!fastShiftList) + return NULL; - const int numShifts = PySequence_Fast_GET_SIZE(shiftList); - double shifts[numShifts][2]; + const Py_ssize_t numShifts = PySequence_Fast_GET_SIZE(fastShiftList); - for (int i = 0; i < numShifts; i++) + if (numShifts < 1) { - PyObject *shiftSet = PySequence_Fast_GET_ITEM(shiftList, i); - shiftSet = PySequence_Fast(shiftSet, "argument must be iterable"); + // translate() reads shifts[0] on the single-shift path, so an empty + // list would run off the end of the array. + PyErr_SetString(PyExc_ValueError, "at least one shift is required"); + Py_DECREF(fastShiftList); + return NULL; + } + + // On the heap rather than a stack VLA: numShifts comes straight from the + // caller, and a long enough list overflows the stack. + std::vector> shiftStore(numShifts); + + for (Py_ssize_t i = 0; i < numShifts; i++) + { + PyObject *shiftSet = PySequence_Fast(PySequence_Fast_GET_ITEM(fastShiftList, i), + "each shift must be a sequence"); + if (!shiftSet) + { + Py_DECREF(fastShiftList); + return NULL; + } + + if (PySequence_Fast_GET_SIZE(shiftSet) < 2) + { + PyErr_SetString(PyExc_ValueError, "each shift needs both an x and a y value"); + Py_DECREF(shiftSet); + Py_DECREF(fastShiftList); + return NULL; + } // Get the x then y coord and convert to pyfloat then c double for (int j = 0; j < 2; j++) { - shifts[i][j] = PyFloat_AS_DOUBLE(PyNumber_Float(PySequence_Fast_GET_ITEM(shiftSet, j))); + PyObject *coord = PyNumber_Float(PySequence_Fast_GET_ITEM(shiftSet, j)); + if (!coord) + { + // PyNumber_Float has already set a TypeError. + Py_DECREF(shiftSet); + Py_DECREF(fastShiftList); + return NULL; + } + shiftStore[i][j] = PyFloat_AS_DOUBLE(coord); + Py_DECREF(coord); } + + Py_DECREF(shiftSet); } - // for (int i = 0; i < numShifts; i++) - // { - // std::cout << shifts[i][0] << ", " << shifts[i][1] << std::endl; - // } + Py_DECREF(fastShiftList); - std::string opath; + // std::array has the same layout as double[2], so translate() + // keeps its existing signature. + double (*shifts)[2] = reinterpret_cast(shiftStore.data()); - Py_DECREF(shiftList); + std::string opath; + std::string failure; Py_UNBLOCK_THREADS debug("Started translating"); // opath is output path if regular, but is actually the gcode preview for preview mode - opath = translate(shifts, numShifts, (std::string) path, - (std::string) sr, (std::string) er, (std::string) ver, preview); + // + // The layer-start and stop patterns come from plugin settings, so an + // invalid one throws std::regex_error in here. Letting a C++ exception + // escape into CPython calls std::terminate, which takes the whole + // OctoPrint process down mid-print -- catch it and raise instead. + try + { + opath = translate(shifts, (int) numShifts, (std::string) path, + (std::string) sr, (std::string) er, (std::string) ver, preview); + } + catch (const std::exception &error) + { + failure = error.what(); + } debug("Done translating"); Py_BLOCK_THREADS + + if (!failure.empty()) + { + PyErr_SetString(PyExc_ValueError, failure.c_str()); + return NULL; + } + return Py_BuildValue("s", opath.c_str()); } diff --git a/tests/test_bad_input.py b/tests/test_bad_input.py new file mode 100644 index 0000000..ad02ff8 --- /dev/null +++ b/tests/test_bad_input.py @@ -0,0 +1,90 @@ +"""The extension must raise on bad input rather than take the process down. + +Every case here crashed the interpreter before the argument handling was +fixed: a segfault for the malformed shift lists, and a std::terminate abort +for the invalid regex. The regex one is the reachable one -- the patterns come +straight from plugin settings, so a typo in a settings field was enough to kill +OctoPrint mid-print. +""" + +import pytest + +from conftest import LAYER_START_REGEX, STOP_REGEX, VERSION + +GOOD_SHIFTS = [(1.0, 1.0)] + + +@pytest.fixture +def path(gcode): + return gcode(["G90", ";LAYER:0", "G1 X10 Y10"]) + + +class TestShiftValidation: + def test_shift_with_one_coordinate(self, translate, path): + with pytest.raises(ValueError): + translate.translate([(1.0,)], path, (LAYER_START_REGEX, STOP_REGEX), VERSION) + + def test_empty_shift(self, translate, path): + with pytest.raises(ValueError): + translate.translate([()], path, (LAYER_START_REGEX, STOP_REGEX), VERSION) + + def test_no_shifts_at_all(self, translate, path): + with pytest.raises(ValueError): + translate.translate([], path, (LAYER_START_REGEX, STOP_REGEX), VERSION) + + def test_non_numeric_coordinates(self, translate, path): + # Same as float("a"): ValueError for an unparseable string, TypeError + # for something with no float conversion at all. + with pytest.raises(ValueError): + translate.translate( + [("a", "b")], path, (LAYER_START_REGEX, STOP_REGEX), VERSION + ) + + def test_shift_that_is_not_a_sequence(self, translate, path): + with pytest.raises(TypeError): + translate.translate([None], path, (LAYER_START_REGEX, STOP_REGEX), VERSION) + + def test_shifts_that_are_not_a_sequence(self, translate, path): + with pytest.raises(TypeError): + translate.translate(None, path, (LAYER_START_REGEX, STOP_REGEX), VERSION) + + def test_extra_coordinates_are_ignored(self, translate, path): + # More than x and y is harmless; only the first two are read. + out = translate.translate( + [(1.0, 2.0, 3.0)], path, (LAYER_START_REGEX, STOP_REGEX), VERSION + ) + assert out.endswith(".gcode") + + def test_a_long_shift_list_does_not_blow_the_stack(self, translate, path): + # This many shifts used to be a 16MB stack-allocated VLA against an 8MB + # stack. It is slow but it must not crash. + out = translate.translate( + [(0.0, 0.0)] * 1000000, path, (LAYER_START_REGEX, STOP_REGEX), VERSION + ) + assert out.endswith(".gcode") + + +class TestRegexValidation: + @pytest.mark.parametrize( + "pattern", ["[unclosed", "(unbalanced", "a{2,1}", "*leading"] + ) + def test_invalid_layer_start_regex_raises(self, translate, path, pattern): + with pytest.raises(ValueError): + translate.translate(GOOD_SHIFTS, path, (pattern, STOP_REGEX), VERSION) + + def test_invalid_stop_regex_raises(self, translate, path): + with pytest.raises(ValueError): + translate.translate( + GOOD_SHIFTS, path, (LAYER_START_REGEX, "[unclosed"), VERSION + ) + + def test_the_process_survives_a_bad_regex(self, translate, path): + # The point of the fix: a bad pattern is recoverable, so a later good + # call still works. + with pytest.raises(ValueError): + translate.translate(GOOD_SHIFTS, path, ("[unclosed", STOP_REGEX), VERSION) + + out = translate.translate( + GOOD_SHIFTS, path, (LAYER_START_REGEX, STOP_REGEX), VERSION + ) + assert out.endswith(".gcode") From 24b6fc6a709221b8f84df7809a9dde0fac9bb7d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:00:31 +0000 Subject: [PATCH 09/28] Stop leaking a reference on every log call The logging wrappers built a message with Py_BuildValue, which already returns a new reference, then took another with Py_XINCREF and released only one. They also dropped the return value of PyObject_CallMethod, which is a new reference too. Measured against the previous commit by watching None's refcount, since logger.debug() returns None: 2000 translate calls leaked 10000 references, five per call. After the fix the same measurement is flat at zero, which also rules out over-releasing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- src/translate.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/translate.cpp b/src/translate.cpp index 8ca14ed..0b37d3d 100644 --- a/src/translate.cpp +++ b/src/translate.cpp @@ -26,8 +26,8 @@ void info(std::string msg) { Py_BLOCK_THREADS PyObject *logging_message = Py_BuildValue("s", msg.c_str()); - Py_XINCREF(logging_message); - PyObject_CallMethod(logging_object, "info", "O", logging_message, NULL); + PyObject *logging_result = PyObject_CallMethod(logging_object, "info", "O", logging_message, NULL); + Py_XDECREF(logging_result); Py_DECREF(logging_message); Py_UNBLOCK_THREADS @@ -38,8 +38,8 @@ void debug(std::string msg) { Py_BLOCK_THREADS PyObject *logging_message = Py_BuildValue("s", msg.c_str()); - Py_XINCREF(logging_message); - PyObject_CallMethod(logging_object, "debug", "O", logging_message, NULL); + PyObject *logging_result = PyObject_CallMethod(logging_object, "debug", "O", logging_message, NULL); + Py_XDECREF(logging_result); Py_DECREF(logging_message); Py_UNBLOCK_THREADS @@ -497,8 +497,8 @@ translate_translate(PyObject *self, PyObject *args) int preview = false; PyObject *logging_message = Py_BuildValue("s", "Before tuple parse"); - Py_XINCREF(logging_message); - PyObject_CallMethod(logging_object, "debug", "O", logging_message, NULL); + PyObject *logging_result = PyObject_CallMethod(logging_object, "debug", "O", logging_message, NULL); + Py_XDECREF(logging_result); Py_DECREF(logging_message); // ADD THE 2 PARENTHESIS AFTER FINAL VAR (THIS HAS HAPPENED TWICE NOW) @@ -508,8 +508,8 @@ translate_translate(PyObject *self, PyObject *args) return NULL; logging_message = Py_BuildValue("s", "After tuple parse"); - Py_XINCREF(logging_message); - PyObject_CallMethod(logging_object, "debug", "O", logging_message, NULL); + logging_result = PyObject_CallMethod(logging_object, "debug", "O", logging_message, NULL); + Py_XDECREF(logging_result); Py_DECREF(logging_message); // parse through all the shifts From b043513d2ccf562eed8a8f9683eb5c8a303f446a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:01:58 +0000 Subject: [PATCH 10/28] Test the Prusa M555 bed-area rewriting M555 tells a Prusa which bed region to probe for G29, and the copies a translate produces sit outside the original footprint, so the probed area has to grow to cover them. The arithmetic that does it -- new origin from the smallest shift, new size grown by the spread between smallest and largest -- had no coverage at all, and getting it wrong misconfigures probing rather than failing loudly. Eight tests over single and multiple shifts, negative shifts, shift order independence, three-way extents, lowercase arguments, the TRANSLATE-MODEL_BED_AREA marker, and gcode with no M555 at all. All pass against the current implementation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- tests/test_m555.py | 88 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/test_m555.py diff --git a/tests/test_m555.py b/tests/test_m555.py new file mode 100644 index 0000000..d54fed6 --- /dev/null +++ b/tests/test_m555.py @@ -0,0 +1,88 @@ +"""Prusa M555 bed-area rewriting. + +M555 tells a Prusa which part of the bed to probe for G29. Copies of a model +sit outside the original model's footprint, so the probed area has to grow to +cover all of them -- get this wrong and the printer probes the wrong region or +refuses the print. + +The rewrite takes the extents of the shifts: the new origin is the original +plus the smallest shift, and the new size is the original size plus the spread +between the smallest and largest shift. +""" + +import pytest + +from conftest import LAYER_START_REGEX, STOP_REGEX, VERSION + + +def m555(lines): + for line in lines: + if line.startswith("M555"): + return line + return None + + +def args(line): + out = {} + for token in line.split(";")[0].split()[1:]: + out[token[0].upper()] = token[1:] + return out + + +class TestBedArea: + def test_single_shift_moves_the_origin_and_keeps_the_size(self, run): + _, lines = run( + ["M555 X0 Y0 W100 H100", "G90", ";LAYER:0", "G1 X1 Y1"], + [(10.0, 20.0)], + ) + assert args(m555(lines)) == {"X": "10", "Y": "20", "W": "100", "H": "100"} + + def test_two_shifts_grow_the_area_by_the_spread(self, run): + # Shifts span 50 in x and 30 in y, so the probed area grows by that + # much in each direction. + _, lines = run( + ["M555 X10 Y10 W100 H80", "G90", ";LAYER:0", "G1 X1 Y1"], + [(0.0, 0.0), (50.0, 30.0)], + ) + assert args(m555(lines)) == {"X": "10", "Y": "10", "W": "150", "H": "110"} + + def test_negative_shifts_pull_the_origin_back(self, run): + _, lines = run( + ["M555 X30 Y30 W50 H50", "G90", ";LAYER:0", "G1 X1 Y1"], + [(-20.0, -5.0), (0.0, 0.0)], + ) + assert args(m555(lines)) == {"X": "10", "Y": "25", "W": "70", "H": "55"} + + def test_origin_comes_from_the_smallest_shift_regardless_of_order(self, run): + # The smallest shift is listed last here; the result must not depend on + # the order the shifts arrive in. + _, lines = run( + ["M555 X0 Y0 W10 H10", "G90", ";LAYER:0", "G1 X1 Y1"], + [(90.0, 90.0), (5.0, 5.0)], + ) + assert args(m555(lines)) == {"X": "5", "Y": "5", "W": "95", "H": "95"} + + def test_three_shifts_use_the_full_extent(self, run): + _, lines = run( + ["M555 X0 Y0 W20 H20", "G90", ";LAYER:0", "G1 X1 Y1"], + [(0.0, 0.0), (40.0, 10.0), (20.0, 60.0)], + ) + assert args(m555(lines)) == {"X": "0", "Y": "0", "W": "60", "H": "80"} + + def test_rewritten_line_is_marked(self, run): + _, lines = run( + ["M555 X0 Y0 W100 H100", "G90", ";LAYER:0", "G1 X1 Y1"], + [(10.0, 10.0)], + ) + assert ";TRANSLATE-MODEL_BED_AREA" in m555(lines) + + def test_lowercase_arguments_are_accepted(self, run): + _, lines = run( + ["M555 x0 y0 w100 h100", "G90", ";LAYER:0", "G1 X1 Y1"], + [(10.0, 20.0)], + ) + assert args(m555(lines)) == {"X": "10", "Y": "20", "W": "100", "H": "100"} + + def test_no_m555_means_no_bed_area_line(self, run): + _, lines = run(["G90", ";LAYER:0", "G1 X1 Y1"], [(10.0, 10.0)]) + assert m555(lines) is None From 440060adfe2ecfcc41a84a88e6bf6eac2db207e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:02:30 +0000 Subject: [PATCH 11/28] Test positioning state across shift copies With more than one shift a layer is buffered and replayed once per copy, so G90/G91 state has to be reset to the layer's entry state before each replay -- otherwise the second copy inherits the first's trailing mode and its moves come out unshifted. translateLine carries a separate flag through the replay loop for exactly this reason, and nothing held it to that. Four tests: each copy starting from the layer entry state, the trailing mode still applying to the following layer, mode switching mid-layer, and the single-shift streaming path tracking the same state. All pass against the current implementation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- tests/test_positioning_state.py | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/test_positioning_state.py diff --git a/tests/test_positioning_state.py b/tests/test_positioning_state.py new file mode 100644 index 0000000..3c09e98 --- /dev/null +++ b/tests/test_positioning_state.py @@ -0,0 +1,68 @@ +"""Absolute/relative positioning state across shift copies. + +With more than one shift a layer is buffered and replayed once per copy, so +G90/G91 state has to be reset to whatever it was when the layer started before +each replay -- otherwise the second copy inherits the first copy's trailing +mode and its moves come out unshifted (or shifted when they should not be). +The implementation carries a separate flag through the replay loop for exactly +this reason; these tests hold it to that. +""" + +from test_translate import coords + + +class TestStateAcrossCopies: + def test_each_copy_starts_from_the_layer_entry_state(self, run): + # The layer ends in relative mode. If the second copy inherited that, + # its X10 would come out unshifted. + _, lines = run( + ["G90", ";LAYER:0", "G1 X10 Y10", "G91", ";LAYER:1", "G1 X30 Y30"], + [(0.0, 0.0), (100.0, 0.0)], + ) + first, second = coords(lines)[0], coords(lines)[1] + assert first == {"X": "10", "Y": "10"} + assert second == {"X": "110", "Y": "10"} + + def test_state_at_the_end_of_a_layer_carries_to_the_next(self, run): + # ...and having replayed the layer, the trailing relative mode does + # apply to what follows, so the next layer's moves are not shifted. + _, lines = run( + ["G90", ";LAYER:0", "G1 X10 Y10", "G91", ";LAYER:1", "G1 X30 Y30"], + [(0.0, 0.0), (100.0, 0.0)], + ) + assert coords(lines)[2:] == [{"X": "30", "Y": "30"}, {"X": "30", "Y": "30"}] + + def test_mode_switching_inside_a_layer(self, run): + _, lines = run( + [ + "G90", + ";LAYER:0", + "G1 X10 Y10", + "G91", + "G1 X5 Y5", + "G90", + "G1 X20 Y20", + ], + [(0.0, 0.0), (100.0, 0.0)], + ) + assert coords(lines) == [ + {"X": "10", "Y": "10"}, + {"X": "5", "Y": "5"}, + {"X": "20", "Y": "20"}, + {"X": "110", "Y": "10"}, + {"X": "5", "Y": "5"}, + {"X": "120", "Y": "20"}, + ] + + def test_single_shift_tracks_mode_inline(self, run): + # The single-shift path streams rather than buffering, but has to track + # the same state. + _, lines = run( + ["G90", ";LAYER:0", "G1 X10 Y10", "G91", "G1 X5 Y5", "G90", "G1 X20 Y20"], + [(7.0, 0.0)], + ) + assert coords(lines) == [ + {"X": "17", "Y": "10"}, + {"X": "5", "Y": "5"}, + {"X": "27", "Y": "20"}, + ] From fcd6ff1c8b7585f3496438871af424c6ded17ce4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:03:06 +0000 Subject: [PATCH 12/28] Pin down what double translation currently does __init__.py has a TODO about handling translation of an already translated file, and it is reachable: output lands beside the input, so it appears in OctoPrint's file list and can be picked again. These tests record the current baseline rather than endorse it. Shifts compound (10 -> 15 -> 20), the output name accumulates suffixes, and the header is written once per pass. The fourth test is the one that matters if this is ever changed: ;TRANSLATE-MODEL_LAYER_START does not match the layer-start pattern, so a second pass does not treat first-pass markers as new layers -- if that ever became true, copies would multiply on every pass instead of adding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- tests/test_double_translate.py | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/test_double_translate.py diff --git a/tests/test_double_translate.py b/tests/test_double_translate.py new file mode 100644 index 0000000..c45b4c0 --- /dev/null +++ b/tests/test_double_translate.py @@ -0,0 +1,71 @@ +"""Translating a file that has already been translated. + +__init__.py carries a TODO about handling this better. It is reachable: the +output lands in the same folder as the input, so it shows up in OctoPrint's +file list and can be picked again. These tests pin down what currently +happens, so that "better" can be defined against a known baseline rather than +a guess. + +Nothing here asserts the behaviour is desirable -- the shifts compound, which +is very likely not what a user picking the file twice expects. +""" + +import os + +from conftest import LAYER_START_REGEX, STOP_REGEX, VERSION +from test_translate import coords + + +def translate_again(translate, path, shifts): + return translate.translate(shifts, path, (LAYER_START_REGEX, STOP_REGEX), VERSION) + + +class TestDoubleTranslate: + def test_shifts_compound(self, translate, gcode): + first_in = gcode(["G90", ";LAYER:0", "G1 X10 Y10"]) + first_out = translate_again(translate, first_in, [(5.0, 5.0)]) + second_out = translate_again(translate, first_out, [(5.0, 5.0)]) + + with open(second_out) as handle: + lines = handle.read().splitlines() + + # 10 -> 15 -> 20, rather than being recognised as already shifted. + assert coords(lines) == [{"X": "20", "Y": "20"}] + + def test_output_name_accumulates(self, translate, gcode): + first_in = gcode(["G90", ";LAYER:0", "G1 X1 Y1"], "cube.gcode") + first_out = translate_again(translate, first_in, [(1.0, 1.0)]) + second_out = translate_again(translate, first_out, [(1.0, 1.0)]) + + assert os.path.basename(first_out) == "cube.translate_1_shifts.gcode" + assert ( + os.path.basename(second_out) + == "cube.translate_1_shifts.translate_1_shifts.gcode" + ) + + def test_header_is_written_twice(self, translate, gcode): + first_in = gcode(["G90", ";LAYER:0", "G1 X1 Y1"]) + first_out = translate_again(translate, first_in, [(1.0, 1.0)]) + second_out = translate_again(translate, first_out, [(1.0, 1.0)]) + + with open(second_out) as handle: + body = handle.read() + + header = "; Processed by OctoPrint-TranslateModel " + VERSION + assert body.count(header) == 2 + + def test_layer_markers_from_the_first_pass_are_not_layer_starts( + self, translate, gcode + ): + # ;TRANSLATE-MODEL_LAYER_START does not match the layer-start pattern, + # so a second pass does not treat the marker as a new layer. If that + # ever changed, copies would multiply on every pass. + first_in = gcode(["G90", ";LAYER:0", "G1 X10 Y10"]) + first_out = translate_again(translate, first_in, [(0.0, 0.0), (50.0, 0.0)]) + second_out = translate_again(translate, first_out, [(0.0, 0.0), (50.0, 0.0)]) + + with open(second_out) as handle: + lines = handle.read().splitlines() + + # Two copies from the first pass, two from the second: four, not more. + assert len(coords(lines)) == 4 From bd197c6aa6427a228f30a92712d90d238fb75d53 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:07:02 +0000 Subject: [PATCH 13/28] Run the test suite under asan and ubsan The systematic version of the bad-input work: rather than guessing which malformed inputs reach a bad pointer, build the extension instrumented and let the sanitizers say so. It would have caught the out-of-bounds shift reads directly instead of by segfault. Two things this needed. libstdc++ has to be preloaded next to libasan -- with only libasan preloaded into an uninstrumented interpreter, asan's __cxa_throw interceptor cannot resolve the real symbol and the first C++ exception dies on a CHECK failure, which the invalid-regex tests trigger deliberately. And ubsan needs halt_on_error, or it prints diagnostics and still exits zero, so undefined behaviour would pass as green. Leak detection stays off: CPython is not instrumented, so its own allocations would bury anything real. This job covers memory errors and UB; the reference-counting side is covered by the refcount measurement in the previous commits. Verified locally: all 50 tests pass instrumented with no findings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/workflows/build.yml | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8e2f35d..2b45b89 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,6 +126,44 @@ jobs: pip install pytest python -m pytest tests -q + sanitizers: + name: asan + ubsan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - name: Build the extension with sanitizers + env: + CFLAGS: "-fsanitize=address,undefined -fno-omit-frame-pointer -g" + LDFLAGS: "-fsanitize=address,undefined" + run: pip wheel --no-deps --wheel-dir dist . + + - name: Install wheel + run: pip install --no-deps dist/*.whl + + # The interpreter is not instrumented, so the runtimes come in through + # LD_PRELOAD. libstdc++ must be preloaded alongside libasan: otherwise + # asan's __cxa_throw interceptor cannot resolve the real symbol and the + # first C++ exception aborts the process with a CHECK failure -- and the + # invalid-regex tests throw on purpose. + # + # Leak detection is off because uninstrumented CPython would drown the + # log in its own allocations; this job is for memory errors and + # undefined behaviour. halt_on_error makes ubsan fail the job instead of + # printing and carrying on, which would let UB pass as green. + - name: Run the tests under the sanitizers + env: + ASAN_OPTIONS: detect_leaks=0 + UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 + run: | + pip install pytest + LD_PRELOAD="$(gcc -print-file-name=libasan.so):$(gcc -print-file-name=libstdc++.so.6)" \ + python -m pytest tests -q + probe-py314: name: py3.14 (probe) runs-on: ubuntu-latest From 568b6e943bd926c27f43f363fb1274fbc77319b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:08:30 +0000 Subject: [PATCH 14/28] Check the sdist can actually build a wheel Every other job builds from the checked-out tree, because that is what OctoPrint's updater installs from a GitHub archive. Nothing exercised the sdist path, so MANIFEST.in dropping src/translate.cpp would only surface after a PyPI upload -- and the 0.3.2 rename suggests PyPI is on the cards. This packs an sdist, builds the wheel from that tarball rather than the tree, then runs the same content checks and translate tests against it. Verified locally: the sdist carries src/translate.cpp and setup.py, and the wheel builds from it cleanly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/workflows/build.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b45b89..880c1bb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,6 +126,39 @@ jobs: pip install pytest python -m pytest tests -q + sdist: + name: sdist round-trip + runs-on: ubuntu-latest + # The build jobs all start from the checked-out tree, which is what + # OctoPrint installs. This one goes through an sdist instead, so a + # MANIFEST.in that stops shipping src/translate.cpp fails here rather than + # on PyPI. + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - name: Build sdist + run: | + pip install build + python -m build --sdist -o dist . + + - name: Build a wheel from the sdist, not from the tree + run: pip wheel --no-deps --wheel-dir from-sdist dist/*.tar.gz + + - name: Install wheel + run: pip install --no-deps from-sdist/*.whl + + - name: Check wheel contents and extension + run: python .github/scripts/check_build.py from-sdist/*.whl + + - name: Run the translate tests + run: | + pip install pytest + python -m pytest tests -q + sanitizers: name: asan + ubsan runs-on: ubuntu-latest From 1facda2027165e18896fbc8648efd196bd2f557e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:09:11 +0000 Subject: [PATCH 15/28] Build and test on arm64 under qemu Every user of this plugin is on a Raspberry Pi and every job so far builds x86. That gap matters for more than whether it compiles: plain `char` is unsigned by default on ARM and signed on x86, and translate.cpp walks gcode a character at a time, so a signedness assumption would produce wrong output only on the hardware nobody tests on. Runs the same build, wheel checks and translate tests inside an arm64 container via qemu. Slower than the native jobs, but it is the only place architecture-specific behaviour can show up before a user's print does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/workflows/build.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 880c1bb..7cf0042 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,6 +126,30 @@ jobs: pip install pytest python -m pytest tests -q + build-arm64: + name: arm64 (qemu) + runs-on: ubuntu-latest + # Every other job builds x86, and every real user is on a Raspberry Pi. + # That matters for more than "it compiles": plain `char` is unsigned by + # default on ARM and signed on x86, and translate.cpp parses gcode a + # character at a time. This is the only job that would catch it. + steps: + - uses: actions/checkout@v7 + + - uses: docker/setup-qemu-action@v4 + with: + platforms: arm64 + + - name: Build and test under arm64 + run: | + docker run --rm --platform linux/arm64 -v "$PWD:/src" -w /src python:3.11-bookworm bash -euc ' + pip wheel --no-deps --wheel-dir dist-arm64 . + pip install --no-deps dist-arm64/*.whl + python .github/scripts/check_build.py dist-arm64/*.whl + pip install pytest + python -m pytest tests -q + ' + sdist: name: sdist round-trip runs-on: ubuntu-latest From 807a851aade0504b02fed8089b59c0b5499abfaa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:10:41 +0000 Subject: [PATCH 16/28] Declare two notification objects instead of leaking them to window runnDict and finishDict were assigned without var/let/const, which makes them implicit globals: they land on window and are shared by every instance of the view model rather than being scoped to the branch that builds them. Both are used only within the block that assigns them, so const is a straight swap. Found by running eslint over the file, which is added in the next commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- octoprint_translatemodel/static/js/translatemodel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/octoprint_translatemodel/static/js/translatemodel.js b/octoprint_translatemodel/static/js/translatemodel.js index 5055dbe..8540032 100644 --- a/octoprint_translatemodel/static/js/translatemodel.js +++ b/octoprint_translatemodel/static/js/translatemodel.js @@ -88,7 +88,7 @@ $(function() { hide: false }); } else if (data.state === "running") { - runnDict = { + const runnDict = { title: 'Running Translating Model', type: 'info', text: `Moving ${_.escape(data.file)}`, @@ -112,7 +112,7 @@ $(function() { else lastText += `is available @ ${_.escape(data.path)}`; - finishDict = { + const finishDict = { type: 'success', title: 'Finished Translating Model', text: `${_.escape(data.file)} moved
took ${_.escape(data.time.toFixed(2))}s ${lastText}`} From ddb15f54d45e0ff30b00f267cd449ee0c723c529 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:11:23 +0000 Subject: [PATCH 17/28] Lint the browser assets static/js is the plugin's browser-facing surface and the part that needed XSS fixes in 0.3.1, and nothing has ever looked at it automatically. eslint's recommended rules plus eslint-plugin-no-unsanitized, with OctoPrint's injected globals declared so the job reports real problems rather than a wall of no-undef. Callback parameters are exempt from no-unused-vars, which is the usual convention and the only rule relaxation here. Worth being clear about the limit: no-unsanitized covers DOM sinks like innerHTML and insertAdjacentHTML, not jQuery's .html(). The one .html() call in this file passes a jQuery object rather than a string, so it is not a sink, but a future string argument would not be flagged by this job. package.json is dev-only tooling -- it is not published and does not reach the wheel, which is built from pyproject.toml. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/workflows/build.yml | 18 ++++++++++++++++++ eslint.config.mjs | 21 +++++++++++++++++++++ package.json | 10 ++++++++++ 3 files changed, 49 insertions(+) create mode 100644 eslint.config.mjs create mode 100644 package.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7cf0042..8199da6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,6 +126,24 @@ jobs: pip install pytest python -m pytest tests -q + lint-js: + name: lint browser assets + runs-on: ubuntu-latest + # static/js is the plugin's browser-facing surface and the part that had + # XSS fixes in 0.3.1, and nothing has ever checked it. + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "22" + + - name: Install linters + run: npm install + + - name: Lint + run: npx eslint octoprint_translatemodel/static/js + build-arm64: name: arm64 (qemu) runs-on: ubuntu-latest diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..0ef758e --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,21 @@ +import js from "@eslint/js"; +import nounsanitized from "eslint-plugin-no-unsanitized"; + +export default [ + js.configs.recommended, + nounsanitized.configs.recommended, + { + languageOptions: { + ecmaVersion: 2021, + sourceType: "script", + globals: { + $: "readonly", jQuery: "readonly", ko: "readonly", _: "readonly", + OctoPrint: "readonly", OCTOPRINT_VIEWMODELS: "writable", + API_BASEURL: "readonly", PNotify: "readonly", GCODE: "readonly", + window: "readonly", document: "readonly", console: "readonly", + setTimeout: "readonly", clearTimeout: "readonly", location: "readonly", + }, + }, + rules: { "no-unused-vars": ["error", { args: "none" }] }, + }, +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..d195cd9 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "octoprint-translatemodel-lint", + "private": true, + "description": "Dev-only tooling for linting the plugin's browser assets. Not published, not shipped in the wheel.", + "devDependencies": { + "@eslint/js": "^10.8.0", + "eslint": "^10.8.0", + "eslint-plugin-no-unsanitized": "^4.1.2" + } +} From c1b9ab56076043debdd85ae51584ea66e142c084 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:12:28 +0000 Subject: [PATCH 18/28] Fail a release tag that disagrees with pyproject.toml The software update plugin fetches archive/.zip and compares the tag against the installed distribution's version, so tagging 0.3.4 while pyproject.toml still says 0.3.3 ships an update that reports the wrong version once installed. The bump has historically been its own commit, which is the kind of step that gets skipped. Runs on tag pushes and compares the two, tolerating a v prefix in case tagging style ever changes. Verified both directions: a matching tag exits 0, a mismatched one exits 1 naming both versions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/scripts/check_release.py | 41 ++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 27 +++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 .github/scripts/check_release.py create mode 100644 .github/workflows/release.yml diff --git a/.github/scripts/check_release.py b/.github/scripts/check_release.py new file mode 100644 index 0000000..8b32fa9 --- /dev/null +++ b/.github/scripts/check_release.py @@ -0,0 +1,41 @@ +"""Check a release tag against the version in pyproject.toml. + + python .github/scripts/check_release.py 0.3.3 + +The software update plugin fetches archive/.zip and compares the tag to +the installed distribution's version, so a tag that disagrees with +pyproject.toml produces an update that either will not install or reports the +wrong version once it has. The version has historically been bumped in its own +commit, which is exactly the kind of step that gets forgotten. +""" + +import sys +import tomllib + + +def main(argv): + if len(argv) != 1: + print("usage: check_release.py ", file=sys.stderr) + return 2 + + tag = argv[0] + # Tags in this repo are bare versions (0.3.2), matching the archive URL in + # get_update_information(); tolerate a v prefix in case that ever changes. + normalised = tag[1:] if tag.startswith("v") else tag + + with open("pyproject.toml", "rb") as handle: + declared = tomllib.load(handle)["project"]["version"] + + if normalised != declared: + print( + "tag {} does not match pyproject.toml version {}".format(tag, declared), + file=sys.stderr, + ) + return 1 + + print("tag {} matches pyproject.toml version {}".format(tag, declared)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7b7c353 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,27 @@ +name: release + +on: + push: + tags: ["*"] + workflow_dispatch: + inputs: + tag: + description: Tag to check against pyproject.toml + required: true + +permissions: + contents: read + +jobs: + tag-matches-version: + name: tag matches pyproject version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - name: Compare tag to pyproject.toml + run: python .github/scripts/check_release.py "${{ inputs.tag || github.ref_name }}" From cc4cc3df04fd4c2db446d49b5aebc83f5e623748 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:14:40 +0000 Subject: [PATCH 19/28] Test the Python half of the plugin Everything so far tested the extension or the packaging; the plugin module itself had no coverage. These tests run against a bare plugin instance with small fakes for the printer, file manager and logger. Covered: the settings defaults, including a check that they are valid patterns for the C++ side (std::regex, not Python's re -- an invalid default would make every translate raise) and that they match the markers Cura and PrusaSlicer actually emit; the software update configuration pointing at this repository and reporting the running version; the declared JS asset existing in the installed package; the API command parameter lists; and the delete-after-print handler, which must fire only for tracked files, only on print-end events, and only for local origin, and must stop tracking the file so a later event cannot delete twice. on_api_command is deliberately not covered: it opens with a flask-principal permission check that needs a real request and app context, which is a bigger harness than the rest of this is worth. The tests skip themselves when OctoPrint is not importable, so the build jobs -- which install with --no-deps -- skip them and the discovery job, which has a real OctoPrint, runs them. Verified both ways: 65 pass with OctoPrint present, 50 pass and 1 skip without. conftest now drops the working directory from sys.path at import time rather than inside the extension loader, because these tests import the installed package at collection time and the repo's source directory would otherwise shadow it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- tests/conftest.py | 12 ++- tests/plugin/test_plugin.py | 176 ++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 tests/plugin/test_plugin.py diff --git a/tests/conftest.py b/tests/conftest.py index a58ac99..d7f8fd1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,13 @@ import pytest +# Drop the working directory before any test module is imported. The repo root +# holds an octoprint_translatemodel/ source directory with no compiled +# extension beside it, which would otherwise shadow the installed package that +# the tests are meant to exercise. +_CWD = os.getcwd() +sys.path[:] = [p for p in sys.path if p not in ("", ".", _CWD)] + PACKAGE = "octoprint_translatemodel" EXT_MODULE = "_translate" @@ -26,11 +33,6 @@ def _load_extension(): - # Keep an in-tree ./octoprint_translatemodel (no compiled extension beside - # it) from shadowing the installed package. - cwd = os.getcwd() - sys.path[:] = [p for p in sys.path if p not in ("", ".", cwd)] - spec = importlib.util.find_spec(PACKAGE) if spec is None or not spec.submodule_search_locations: raise RuntimeError( diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py new file mode 100644 index 0000000..c6219d8 --- /dev/null +++ b/tests/plugin/test_plugin.py @@ -0,0 +1,176 @@ +"""Tests for the Python half of the plugin. + +Skipped unless OctoPrint is importable, so the build jobs (which install with +--no-deps) skip them and the discovery job runs them. + +Scope note: on_api_command is not covered. Its first act is a permission check +through flask-principal, which needs a real request and app context to +evaluate -- standing that up is a bigger harness than this. What is covered is +everything reachable from a bare plugin instance. +""" + +import pytest + +pytest.importorskip("octoprint") + +from conftest import LAYER_START_REGEX, STOP_REGEX, VERSION # noqa: E402 + +import octoprint_translatemodel # noqa: E402 + + +@pytest.fixture +def plugin(): + instance = octoprint_translatemodel.TranslatemodelPlugin() + instance._plugin_version = "9.9.9" + # Class attributes, so they persist between instances; reset per test. + instance.translating = [] + instance.delete_files = [] + return instance + + +class TestSettingsDefaults: + def test_both_patterns_are_present(self, plugin): + defaults = plugin.get_settings_defaults() + assert defaults["layerStartRegex"] + assert defaults["stopRegex"] + + def test_defaults_match_what_the_tests_exercise(self, plugin): + # The extension tests hard-code these; if the defaults move, the tests + # stop covering what users actually run. + defaults = plugin.get_settings_defaults() + assert defaults["layerStartRegex"] == LAYER_START_REGEX + assert defaults["stopRegex"] == STOP_REGEX + + def test_defaults_are_valid_for_the_extension(self, plugin, translate, gcode): + # The C++ side compiles these with std::regex, not Python's re. An + # invalid default would make every translate raise. + defaults = plugin.get_settings_defaults() + path = gcode(["G90", ";LAYER:0", "G1 X1 Y1"]) + out = translate.translate( + [(1.0, 1.0)], + path, + (defaults["layerStartRegex"], defaults["stopRegex"]), + VERSION, + ) + assert out.endswith(".gcode") + + def test_the_default_layer_pattern_matches_real_slicer_output( + self, plugin, translate, gcode + ): + # Cura writes ;LAYER:n, PrusaSlicer writes ;LAYER_CHANGE. + defaults = plugin.get_settings_defaults() + for marker in (";LAYER:0", ";LAYER_CHANGE"): + path = gcode(["G90", marker, "G1 X10 Y10"], "m.gcode") + out = translate.translate( + [(5.0, 5.0)], + path, + (defaults["layerStartRegex"], defaults["stopRegex"]), + VERSION, + ) + with open(out) as handle: + body = handle.read() + assert "X15 Y15" in body, marker + + +class TestUpdateInformation: + def test_points_at_this_repository(self, plugin): + info = plugin.get_update_information()["translatemodel"] + assert info["user"] == "Willmac16" + assert info["repo"] == "OctoPrint-TranslateModel" + assert info["type"] == "github_release" + + def test_reports_the_running_version(self, plugin): + info = plugin.get_update_information()["translatemodel"] + assert info["current"] == "9.9.9" + assert info["displayVersion"] == "9.9.9" + + def test_pip_url_is_templated_on_the_target_version(self, plugin): + info = plugin.get_update_information()["translatemodel"] + assert "{target_version}" in info["pip"] + # The release guard checks tags against pyproject.toml precisely + # because this URL resolves a tag to an archive. + assert info["pip"].endswith("archive/{target_version}.zip") + + +class TestAssets: + def test_declared_js_is_actually_packaged(self, plugin): + import os + + assets = plugin.get_assets() + package_dir = os.path.dirname(octoprint_translatemodel.__file__) + for relative in assets["js"]: + assert os.path.exists(os.path.join(package_dir, "static", relative)) + + +class TestApiCommands: + def test_commands_and_their_required_parameters(self, plugin): + commands = plugin.get_api_commands() + assert commands["translate"] == ["file", "shifts", "at"] + assert commands["preview"] == ["file", "shifts"] + assert commands["test"] == [] + + +class FakePrinter: + def __init__(self): + self.unselected = False + + def unselect_file(self): + self.unselected = True + + +class FakeFileManager: + def __init__(self): + self.removed = [] + + def remove_file(self, destination, path): + self.removed.append((destination, path)) + + +class FakeLogger: + def info(self, *args, **kwargs): + pass + + def debug(self, *args, **kwargs): + pass + + +@pytest.fixture +def wired(plugin): + plugin._logger = FakeLogger() + plugin._printer = FakePrinter() + plugin._file_manager = FakeFileManager() + return plugin + + +class TestDeleteAfterPrint: + @pytest.mark.parametrize("event", ["PrintDone", "PrintFailed", "PrintCanceled"]) + def test_tracked_file_is_removed_when_a_print_ends(self, wired, event): + wired.delete_files.append("copies.gcode") + wired.on_event(event, {"origin": "local", "path": "copies.gcode"}) + + assert wired._file_manager.removed == [("local", "copies.gcode")] + assert wired._printer.unselected is True + # Removed from tracking too, so a later event cannot delete twice. + assert wired.delete_files == [] + + def test_untracked_file_is_left_alone(self, wired): + wired.delete_files.append("copies.gcode") + wired.on_event("PrintDone", {"origin": "local", "path": "something-else.gcode"}) + + assert wired._file_manager.removed == [] + assert wired.delete_files == ["copies.gcode"] + + def test_other_events_do_nothing(self, wired): + wired.delete_files.append("copies.gcode") + wired.on_event("PrintStarted", {"origin": "local", "path": "copies.gcode"}) + + assert wired._file_manager.removed == [] + assert wired.delete_files == ["copies.gcode"] + + def test_non_local_origin_is_ignored(self, wired): + # Only files this plugin wrote to local storage should be deleted. + wired.delete_files.append("copies.gcode") + wired.on_event("PrintDone", {"origin": "sdcard", "path": "copies.gcode"}) + + assert wired._file_manager.removed == [] + assert wired.delete_files == ["copies.gcode"] From 8f432679eb952304668319c4efacb87437d1da92 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:15:56 +0000 Subject: [PATCH 20/28] Fix the @eslint/js version range The lint job failed on npm error notarget: @eslint/js is versioned independently of eslint, and I wrote ^10.8.0 for both by assuming they track together. eslint is 10.8.0; @eslint/js is 10.0.1. Verified this time from a clean npm install against package.json, which is what CI does, rather than from packages installed ad hoc. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d195cd9..9dc784e 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,8 @@ "private": true, "description": "Dev-only tooling for linting the plugin's browser assets. Not published, not shipped in the wheel.", "devDependencies": { - "@eslint/js": "^10.8.0", + "@eslint/js": "^10.0.1", "eslint": "^10.8.0", - "eslint-plugin-no-unsanitized": "^4.1.2" + "eslint-plugin-no-unsanitized": "^4.1.5" } } From a6907f645ed328e3da963e97826ef26c3ffe8e53 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:34:30 +0000 Subject: [PATCH 21/28] Use PyImport_ImportModule instead of the removed NoBlock alias The 3.14 probe passes but warns: PyImport_ImportModuleNoBlock is deprecated and removed in Python 3.15. It has been nothing but an alias for PyImport_ImportModule since Python 3.3, so this is behaviour-neutral today and the difference between building and not building on 3.15. Verified with deprecation warnings turned into errors: 65 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- src/translate.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/translate.cpp b/src/translate.cpp index 0b37d3d..a3f3bf2 100644 --- a/src/translate.cpp +++ b/src/translate.cpp @@ -671,7 +671,9 @@ static struct PyModuleDef translatemodule = { PyMODINIT_FUNC PyInit__translate(void) { - logging_library = PyImport_ImportModuleNoBlock("logging"); + // PyImport_ImportModuleNoBlock has been a plain alias for this since + // Python 3.3, was deprecated in 3.13 and is removed in 3.15. + logging_library = PyImport_ImportModule("logging"); logging_object = PyObject_CallMethod(logging_library, "getLogger", "O", Py_BuildValue("s", module_name)); Py_XINCREF(logging_object); From 10bbddc2c733ba29ab8b88bc579983aa33edfbad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:35:23 +0000 Subject: [PATCH 22/28] Support Python 3.14, and probe 3.15 instead The probe answered its question over two runs: 3.14 builds, installs and passes the whole suite. With the NoBlock alias gone in the previous commit there is nothing left holding the cap at <3.14, and leaving it there refuses installation for anyone on 3.14 -- a version OctoPrint 2.0 supports. So raise requires-python to <3.15 and move 3.14 from the probe into the real build matrix, where it gates like every other version rather than being informational. The probe itself moves up to 3.15, which is in beta. It is the same arrangement: forced past the cap with --ignore-requires-python, continue-on-error because a failure there is a version this package declares unsupported. It doubles as a check on the previous commit -- 3.15 is where PyImport_ImportModuleNoBlock actually disappears, so a green 3.15 probe is the proof the swap was both needed and sufficient. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/workflows/build.yml | 20 +++++++++++--------- pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8199da6..9986215 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v7 @@ -239,14 +239,15 @@ jobs: LD_PRELOAD="$(gcc -print-file-name=libasan.so):$(gcc -print-file-name=libstdc++.so.6)" \ python -m pytest tests -q - probe-py314: - name: py3.14 (probe) + probe-py315: + name: py3.15 (probe) runs-on: ubuntu-latest - # requires-python caps at <3.14, so pip refuses 3.14 outright; this forces - # the build to answer whether that cap can be lifted. 3.14 shipped in - # October 2025 and OctoPrint 2.0 allows up to <3.15, so the cap will start - # excluding people. The thing most likely to break is - # PyImport_ImportModuleNoBlock in translate.cpp, deprecated since 3.13. + # The same trick the 3.14 probe used before 3.14 was promoted into the + # matrix above: requires-python caps at <3.15, so pip refuses 3.15 + # outright, and this forces the build to answer whether the next cap can + # be lifted too. 3.15 is in beta; its headline risk for this extension was + # PyImport_ImportModuleNoBlock, which 3.15 removes and which translate.cpp + # no longer calls. # # Informational, hence continue-on-error: a failure here is a version this # package already declares unsupported, not a regression, so unlike the @@ -257,7 +258,8 @@ jobs: - uses: actions/setup-python@v7 with: - python-version: "3.14" + python-version: "3.15" + allow-prereleases: true - name: Build wheel run: pip wheel --no-deps --ignore-requires-python --wheel-dir dist . diff --git a/pyproject.toml b/pyproject.toml index ff20260..5083df9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ authors = [ {name = "Will MacCormack", email = "willmaccormack@gmail.com"} ] readme = {file = "README.md", content-type = "text/markdown"} -requires-python = ">=3.7, <3.14" +requires-python = ">=3.7, <3.15" dependencies = ["OctoPrint"] [project.urls] From 8df430c191bd5f855d2ff82d63db0f628c83b5eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:35:53 +0000 Subject: [PATCH 23/28] Check discovery on the combination 3.14 opens up Raising requires-python to <3.15 means a 3.14 user can install the plugin, and OctoPrint 1.11 caps at <3.14, so the only OctoPrint they can be running is 2.0. That pairing had no coverage: both discovery legs ran on 3.11. The matrix now carries a python version per leg and adds 2.0 on 3.14. Whether OctoPrint 2.0's own dependencies have 3.14 wheels yet is exactly what this will tell us, and it is worth knowing before a user finds out. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/workflows/build.yml | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9986215..bf7cc80 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,7 +55,7 @@ jobs: python -m pytest tests -q plugin-loads: - name: OctoPrint plugin discovery (${{ matrix.octoprint }}) + name: OctoPrint plugin discovery (${{ matrix.octoprint }}, py${{ matrix.python }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -63,17 +63,26 @@ jobs: # stable is whatever `pip install OctoPrint` gives a user today; 2.0 is # the next major, in rc since April 2026 and the release that raises # OctoPrint's own Python floor to 3.9. - octoprint: [stable, "2.0-prerelease"] - # 3.11 is what OctoPi's bookworm image ships, so it is the most - # representative single version to test discovery on, and it is supported by - # both OctoPrint 1.11 and 2.0. (It is also the floor for the tomllib the - # check script uses to read pyproject.toml.) + # + # 3.11 is what OctoPi's bookworm image ships, so it is the most + # representative pairing for both majors. (It is also the floor for the + # tomllib the check script uses to read pyproject.toml.) + include: + - octoprint: stable + python: "3.11" + - octoprint: "2.0-prerelease" + python: "3.11" + # OctoPrint 1.11 caps at <3.14, so 2.0 is the only major a 3.14 user + # can run -- this is the combination that raising requires-python + # actually opens up. + - octoprint: "2.0-prerelease" + python: "3.14" steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 with: - python-version: "3.11" + python-version: ${{ matrix.python }} - name: Build wheel run: pip wheel --no-deps --wheel-dir dist . From b3aeb15a331a620deb6d0d529927cf1dcaab6004 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:36:22 +0000 Subject: [PATCH 24/28] Have Dependabot watch the lint tooling too package.json has no lockfile, so npm resolves within the caret ranges on every run and a new eslint major is only noticed when the job breaks -- which is precisely how the @eslint/js mistake surfaced. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 593e6bd..783ad22 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,14 @@ updates: actions: patterns: - "*" + + # The lint tooling in package.json, which has no lockfile -- so without this + # a new eslint major is only noticed when the job breaks. + - package-ecosystem: npm + directory: / + schedule: + interval: monthly + groups: + lint: + patterns: + - "*" From 1dd49f81443f0d01e6580c46ca64b971cba58c92 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:37:53 +0000 Subject: [PATCH 25/28] Test permission gating on the API commands on_api_command is the plugin's only externally reachable entry point and every branch of it is guarded by an OctoPrint permission, which is why I left it uncovered earlier -- making those checks evaluate looked like it needed a flask request context. It does not. The plugin reads Permissions as a module attribute, so swapping in a stub exercises the real branching, and stubbing TranslateWorker keeps the tests from spawning threads or touching disk while still capturing what the plugin decided to hand it. Fourteen tests: FILES_UPLOAD gating translate and preview; the after-translate downgrade ladder, where load needs FILES_SELECT, print falls back to load without PRINT and to nothing without FILES_SELECT, and printAndDelete is treated like print; shift coercion; settings patterns reaching the worker; non-gcode rejection; preview always previewing; and the test command starting nothing. valid_file_type is stubbed as well -- the real one asks the plugin manager which extensions are registered, and which extensions count as gcode is OctoPrint's business rather than this plugin's. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- tests/plugin/test_api_permissions.py | 187 +++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tests/plugin/test_api_permissions.py diff --git a/tests/plugin/test_api_permissions.py b/tests/plugin/test_api_permissions.py new file mode 100644 index 0000000..4d170a9 --- /dev/null +++ b/tests/plugin/test_api_permissions.py @@ -0,0 +1,187 @@ +"""Permission gating on the plugin's API commands. + +on_api_command is the plugin's only externally reachable entry point, and +every branch of it is guarded by an OctoPrint permission. Rather than stand up +a flask request context to make those checks evaluate, the Permissions object +the module imported is swapped for a stub -- the plugin reads it by module +attribute, so this exercises the real branching. + +TranslateWorker is stubbed too, so nothing actually spawns a thread or touches +the disk; the tests assert on what the plugin decided to hand it. +""" + +import pytest + +pytest.importorskip("octoprint") + +import octoprint.filemanager # noqa: E402 +import octoprint_translatemodel # noqa: E402 + + +def fake_valid_file_type(name, type=None): + """Stand-in for octoprint.filemanager.valid_file_type. + + The real one asks the plugin manager which extensions are registered, which + needs a booted OctoPrint. Which extensions count as gcode is OctoPrint's + business anyway; what matters here is the plugin's branching on the answer. + """ + return name.endswith((".gcode", ".gco", ".g")) + + +class FakePermission: + def __init__(self, allowed): + self.allowed = allowed + + def can(self): + return self.allowed + + +class FakePermissions: + def __init__(self, upload=True, select=True, print_=True): + self.FILES_UPLOAD = FakePermission(upload) + self.FILES_SELECT = FakePermission(select) + self.PRINT = FakePermission(print_) + + +class RecordingWorker: + started = [] + + def __init__(self, plugin, shifts, file, after_translate, regexTuple, index): + self.args = dict( + shifts=shifts, + file=file, + after_translate=after_translate, + regexTuple=regexTuple, + index=index, + ) + + def start(self): + RecordingWorker.started.append(self.args) + + +class FakeSettings: + def get(self, keys): + return {"layerStartRegex": "^;LAYER", "stopRegex": "(end)"}[keys[0]] + + +class FakeLogger: + def info(self, *args, **kwargs): + pass + + def debug(self, *args, **kwargs): + pass + + +class FakePluginManager: + def __init__(self): + self.messages = [] + + def send_plugin_message(self, identifier, payload): + self.messages.append(payload) + + +@pytest.fixture +def api(monkeypatch): + """A plugin instance with permissions and the worker stubbed out.""" + RecordingWorker.started = [] + monkeypatch.setattr(octoprint_translatemodel, "TranslateWorker", RecordingWorker) + monkeypatch.setattr( + octoprint.filemanager, "valid_file_type", fake_valid_file_type + ) + + plugin = octoprint_translatemodel.TranslatemodelPlugin() + plugin.translating = [] + plugin.delete_files = [] + plugin._logger = FakeLogger() + plugin._settings = FakeSettings() + plugin._plugin_manager = FakePluginManager() + + def configure(**kwargs): + monkeypatch.setattr( + octoprint_translatemodel, "Permissions", FakePermissions(**kwargs) + ) + return plugin + + plugin.configure = configure + return plugin + + +def translate_request(at="nothing", file="cube.gcode", shifts=((10, 20),)): + return {"file": file, "shifts": [list(s) for s in shifts], "at": at} + + +class TestUploadPermission: + def test_translate_needs_files_upload(self, api): + plugin = api.configure(upload=False) + plugin.on_api_command("translate", translate_request()) + assert RecordingWorker.started == [] + + def test_preview_needs_files_upload(self, api): + plugin = api.configure(upload=False) + plugin.on_api_command("preview", translate_request()) + assert RecordingWorker.started == [] + + def test_translate_runs_with_files_upload(self, api): + plugin = api.configure(upload=True) + plugin.on_api_command("translate", translate_request()) + assert len(RecordingWorker.started) == 1 + + +class TestAfterTranslateIsDowngraded: + def test_load_requires_files_select(self, api): + plugin = api.configure(select=False) + plugin.on_api_command("translate", translate_request(at="load")) + assert RecordingWorker.started[0]["after_translate"] == "" + + def test_load_is_allowed_with_files_select(self, api): + plugin = api.configure(select=True) + plugin.on_api_command("translate", translate_request(at="load")) + assert RecordingWorker.started[0]["after_translate"] == "load" + + def test_print_without_print_permission_falls_back_to_load(self, api): + plugin = api.configure(select=True, print_=False) + plugin.on_api_command("translate", translate_request(at="print")) + assert RecordingWorker.started[0]["after_translate"] == "load" + + def test_print_without_files_select_does_nothing_after(self, api): + plugin = api.configure(select=False, print_=True) + plugin.on_api_command("translate", translate_request(at="print")) + assert RecordingWorker.started[0]["after_translate"] == "nothing" + + def test_print_is_allowed_with_both(self, api): + plugin = api.configure(select=True, print_=True) + plugin.on_api_command("translate", translate_request(at="print")) + assert RecordingWorker.started[0]["after_translate"] == "print" + + def test_print_and_delete_needs_both_too(self, api): + plugin = api.configure(select=True, print_=False) + plugin.on_api_command("translate", translate_request(at="printAndDelete")) + assert RecordingWorker.started[0]["after_translate"] == "load" + + +class TestRequestHandling: + def test_shifts_are_coerced_to_float_pairs(self, api): + plugin = api.configure() + plugin.on_api_command("translate", translate_request(shifts=(("10", "20.5"),))) + assert RecordingWorker.started[0]["shifts"] == [(10.0, 20.5)] + + def test_settings_patterns_are_passed_through(self, api): + plugin = api.configure() + plugin.on_api_command("translate", translate_request()) + assert RecordingWorker.started[0]["regexTuple"] == ("^;LAYER", "(end)") + + def test_non_gcode_is_rejected(self, api): + plugin = api.configure() + plugin.on_api_command("translate", translate_request(file="notes.txt")) + assert RecordingWorker.started == [] + assert plugin._plugin_manager.messages[-1]["state"] == "invalid" + + def test_preview_always_previews(self, api): + plugin = api.configure() + plugin.on_api_command("preview", translate_request(at="print")) + assert RecordingWorker.started[0]["after_translate"] == "preview" + + def test_test_command_starts_nothing(self, api): + plugin = api.configure() + plugin.on_api_command("test", {}) + assert RecordingWorker.started == [] From 4232d8228c138f4b04437185c4aa1caa00284236 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:39:07 +0000 Subject: [PATCH 26/28] Fix UnboundLocalError when the same translate is requested twice Submitting the same file with the same shifts while the first translate is still running raised UnboundLocalError instead of reporting that it was already in flight: the duplicate branch reads len(shifts), but shifts is only bound in the branch that starts a worker. The user got a 500 and the UI never received the "running" notification it was meant to show. len(data['shifts']) is the count that branch was reaching for -- the number of shifts requested, which is what the message reports. Found while writing the API permission tests in the previous commit; the two new tests cover the duplicate path and confirm that a request with different shifts is still treated as new work. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- octoprint_translatemodel/__init__.py | 2 +- tests/plugin/test_api_permissions.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/octoprint_translatemodel/__init__.py b/octoprint_translatemodel/__init__.py index 3e17026..71155de 100644 --- a/octoprint_translatemodel/__init__.py +++ b/octoprint_translatemodel/__init__.py @@ -173,7 +173,7 @@ def on_api_command(self, command, data): ), index) worker.start() else: - self._plugin_manager.send_plugin_message("translatemodel", dict(state='running', file=data['file'], shifts=len(shifts), index=index)) + self._plugin_manager.send_plugin_message("translatemodel", dict(state='running', file=data['file'], shifts=len(data['shifts']), index=index)) else: self._plugin_manager.send_plugin_message("translatemodel", dict(state='invalid', file=data['file'])) elif command == "preview": diff --git a/tests/plugin/test_api_permissions.py b/tests/plugin/test_api_permissions.py index 4d170a9..9020cb0 100644 --- a/tests/plugin/test_api_permissions.py +++ b/tests/plugin/test_api_permissions.py @@ -181,6 +181,25 @@ def test_preview_always_previews(self, api): plugin.on_api_command("preview", translate_request(at="print")) assert RecordingWorker.started[0]["after_translate"] == "preview" + def test_duplicate_request_reports_running_instead_of_starting_again(self, api): + # The same file with the same shifts, while the first is still in + # flight. This used to raise UnboundLocalError, so the UI got a 500 + # rather than the "already running" notification. + plugin = api.configure() + plugin.on_api_command("translate", translate_request()) + plugin.on_api_command("translate", translate_request()) + + assert len(RecordingWorker.started) == 1 + assert plugin._plugin_manager.messages[-1]["state"] == "running" + assert plugin._plugin_manager.messages[-1]["shifts"] == 1 + + def test_different_shifts_are_not_a_duplicate(self, api): + plugin = api.configure() + plugin.on_api_command("translate", translate_request(shifts=((10, 20),))) + plugin.on_api_command("translate", translate_request(shifts=((30, 40),))) + + assert len(RecordingWorker.started) == 2 + def test_test_command_starts_nothing(self, api): plugin = api.configure() plugin.on_api_command("test", {}) From e33faa008ef8a4603a9512bebc194962add382fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:45:22 +0000 Subject: [PATCH 27/28] Do not gate on the 3.14 discovery leg yet It failed, and the reason is nothing this plugin controls: installing OctoPrint 2.0 on 3.14 pulls a PyYAML with no 3.14 wheel, so pip builds it from source and its setup.py dies on modern setuptools with AttributeError: 'build_ext' object has no attribute 'cython_sources'. The plugin itself is fine on 3.14 -- the gating py3.14 build job and the 3.15 probe both pass. So this leg becomes informational, like the version probe, and its going green later is the signal that the combination has become usable. Worth recording plainly: this means nobody can actually run OctoPrint on 3.14 today, since 1.11 caps at <3.14 and 2.0's dependencies will not install there. Raising requires-python to <3.15 does not unlock anyone yet; it just stops this plugin from being the thing in the way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/workflows/build.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bf7cc80..66db154 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,9 +74,14 @@ jobs: python: "3.11" # OctoPrint 1.11 caps at <3.14, so 2.0 is the only major a 3.14 user # can run -- this is the combination that raising requires-python - # actually opens up. + # opens up. It does not install yet, and not for any reason this + # plugin controls: PyYAML has no 3.14 wheel at the version 2.0 + # resolves, so pip builds it from source and its setup.py fails + # against modern setuptools. Informational until that clears, at + # which point this leg going green is the signal. - octoprint: "2.0-prerelease" python: "3.14" + continue-on-error: ${{ matrix.python == '3.14' }} steps: - uses: actions/checkout@v7 From e1389aefca1395fa2d89d6526e23d7659406c7f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:52:22 +0000 Subject: [PATCH 28/28] Make the 3.14 leg a canary rather than a standing red X Job-level continue-on-error keeps the workflow run green but still marks the job's check as failed, so the previous commit left a permanently red entry in the PR's checks list for a job that provides no coverage at all today. A red X nobody is expected to act on is worse than no job: it teaches people to skim past red. Instead the install steps are allowed to fail on 3.14 specifically, the discovery checks are skipped when they do, and a notice says why. The job goes green with an explanation rather than red with none, and the moment OctoPrint 2.0 installs on 3.14 the checks start running for real with no change to this file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwbfqGAtMzTw1dhLusMkva --- .github/workflows/build.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 66db154..ef8aebe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -77,11 +77,15 @@ jobs: # opens up. It does not install yet, and not for any reason this # plugin controls: PyYAML has no 3.14 wheel at the version 2.0 # resolves, so pip builds it from source and its setup.py fails - # against modern setuptools. Informational until that clears, at - # which point this leg going green is the signal. + # against modern setuptools. + # + # So this leg is a canary: the install is allowed to fail on 3.14 and + # the checks are skipped with a notice, rather than leaving a red X + # standing permanently for something outside this repo. The moment + # OctoPrint 2.0 installs on 3.14, the checks below start running for + # real without any change here. - octoprint: "2.0-prerelease" python: "3.14" - continue-on-error: ${{ matrix.python == '3.14' }} steps: - uses: actions/checkout@v7 @@ -95,19 +99,30 @@ jobs: # Unlike the build jobs, this one installs dependencies: the point is to # load the plugin against a real OctoPrint. - name: Install wheel with OctoPrint + id: install + continue-on-error: ${{ matrix.python == '3.14' }} run: pip install dist/*.whl # Upgrading after the fact rather than resolving 2.0 up front, because # that is the order real users hit it: plugin already installed, then # OctoPrint moves to the new major. - name: Upgrade to the OctoPrint 2.0 prerelease - if: matrix.octoprint == '2.0-prerelease' + id: upgrade + if: matrix.octoprint == '2.0-prerelease' && steps.install.outcome == 'success' + continue-on-error: ${{ matrix.python == '3.14' }} run: pip install --upgrade --pre "OctoPrint>=2.0.0rc1,<3" + - name: Note that OctoPrint is not installable here yet + if: steps.install.outcome == 'failure' || steps.upgrade.outcome == 'failure' + run: | + echo "::notice::OctoPrint ${{ matrix.octoprint }} still does not install on Python ${{ matrix.python }}; discovery checks skipped." + - name: Report OctoPrint version + if: steps.install.outcome == 'success' && steps.upgrade.outcome != 'failure' run: python -c "import octoprint; print(octoprint.__version__)" - name: Check OctoPrint discovers and loads the plugin + if: steps.install.outcome == 'success' && steps.upgrade.outcome != 'failure' run: python .github/scripts/check_plugin_load.py build-py37: