Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a59c7fb
Restore Python 3.7 buildability, bump to 0.3.3
claude Jul 30, 2026
a10d1fc
Add CI that builds the plugin on every supported Python
claude Jul 30, 2026
559135e
Run CI once per commit, not twice
claude Jul 30, 2026
1e2f0ef
Add a CI job that loads the plugin against real OctoPrint
claude Jul 30, 2026
a892682
Also check plugin discovery against the OctoPrint 2.0 prerelease
claude Jul 30, 2026
070580f
Bump checkout and setup-python to v7
claude Jul 30, 2026
31700e8
Test the translation logic, probe 3.14, add Dependabot
claude Jul 30, 2026
762b041
Stop bad input from crashing the extension
claude Jul 31, 2026
24b6fc6
Stop leaking a reference on every log call
claude Jul 31, 2026
b043513
Test the Prusa M555 bed-area rewriting
claude Jul 31, 2026
440060a
Test positioning state across shift copies
claude Jul 31, 2026
fcd6ff1
Pin down what double translation currently does
claude Jul 31, 2026
bd197c6
Run the test suite under asan and ubsan
claude Jul 31, 2026
568b6e9
Check the sdist can actually build a wheel
claude Jul 31, 2026
1facda2
Build and test on arm64 under qemu
claude Jul 31, 2026
807a851
Declare two notification objects instead of leaking them to window
claude Jul 31, 2026
ddb15f5
Lint the browser assets
claude Jul 31, 2026
c1b9ab5
Fail a release tag that disagrees with pyproject.toml
claude Jul 31, 2026
cc4cc3d
Test the Python half of the plugin
claude Jul 31, 2026
8f43267
Fix the @eslint/js version range
claude Jul 31, 2026
a6907f6
Use PyImport_ImportModule instead of the removed NoBlock alias
claude Jul 31, 2026
10bbddc
Support Python 3.14, and probe 3.15 instead
claude Jul 31, 2026
8df430c
Check discovery on the combination 3.14 opens up
claude Jul 31, 2026
b3aeb15
Have Dependabot watch the lint tooling too
claude Jul 31, 2026
1dd49f8
Test permission gating on the API commands
claude Jul 31, 2026
4232d82
Fix UnboundLocalError when the same translate is requested twice
claude Jul 31, 2026
e33faa0
Do not gate on the 3.14 discovery leg yet
claude Jul 31, 2026
e1389ae
Make the 3.14 leg a canary rather than a standing red X
claude Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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:
- "*"
191 changes: 191 additions & 0 deletions .github/scripts/check_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""Structural and import checks for a built OctoPrint-TranslateModel wheel.

Usage, after `pip install --no-deps <wheel>`:

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 <wheel>", 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:]))
141 changes: 141 additions & 0 deletions .github/scripts/check_plugin_load.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Assert that OctoPrint can discover and load the installed plugin.

Usage, after `pip install <wheel>` (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())
Loading
Loading