diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..783ad22 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +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: + - "*" + + # 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: + - "*" 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/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/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/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ef8aebe --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,305 @@ +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] + 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", "3.14"] + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + 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 + + # 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 }}, py${{ matrix.python }}) + 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. + # + # 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 + # 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. + # + # 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" + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python }} + + - 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 + 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 + 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: + 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: 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@v7 + + - 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 + + # 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 + + 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 + # 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 + # 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 + 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-py315: + name: py3.15 (probe) + runs-on: ubuntu-latest + # 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 + # 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.15" + allow-prereleases: true + + - 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/.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 }}" 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/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/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}`} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9dc784e --- /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.0.1", + "eslint": "^10.8.0", + "eslint-plugin-no-unsanitized": "^4.1.5" + } +} diff --git a/pyproject.toml b/pyproject.toml index 4f49c42..5083df9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,26 @@ [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"} ] readme = {file = "README.md", content-type = "text/markdown"} -requires-python = ">=3.7, <3.14" +requires-python = ">=3.7, <3.15" dependencies = ["OctoPrint"] [project.urls] @@ -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"], + ) + ] +) diff --git a/src/translate.cpp b/src/translate.cpp index fff08ad..a3f3bf2 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++"; @@ -24,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 @@ -36,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 @@ -495,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) @@ -504,50 +506,104 @@ 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); - 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 - 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()); } @@ -615,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); diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d7f8fd1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,95 @@ +"""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 + +# 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" + +# 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(): + 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/plugin/test_api_permissions.py b/tests/plugin/test_api_permissions.py new file mode 100644 index 0000000..9020cb0 --- /dev/null +++ b/tests/plugin/test_api_permissions.py @@ -0,0 +1,206 @@ +"""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_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", {}) + assert RecordingWorker.started == [] 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"] 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") 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 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 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"}, + ] 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")