diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9daf3161..c04a1894 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 + rev: v6.0.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 26.3.1 hooks: - id: black - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/.travis.yml b/.travis.yml index 144f2f62..3d37864d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,7 @@ jobs: - pip install setuptools==60.9.0 # https://github.com/pypa/setuptools/issues/3293 - pip install OctoPrint # Need OctoPrint to satisfy req's of `__init__.py` - pip install coverage coveralls - - pip install -r requirements.txt + - pip install -e . script: - coverage run -m unittest discover -p "*_test.py" after_success: diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 00000000..1c60fc66 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,86 @@ + +# Taskfile to be used with task: https://taskfile.dev +# +# A copy of task gets automatically installed as a "develop" dependency this plugin: +# +# pip install .[develop] +# go-task --list-all +# + +version: "3" + +env: + LOCALES: [] # list your included locales here, e.g. ["de", "fr"] + TRANSLATIONS: "continuousprint/translations" # translations folder + + +tasks: + install: + desc: Installs the plugin into the current venv + cmds: + - "python -m pip install -e .[develop]" + + ### Build related + + build: + desc: Builds sdist & wheel + cmds: + - python -m build --sdist --wheel + + build-sdist: + desc: Builds sdist + cmds: + - python -m build --sdist + + build-wheel: + desc: Builds wheel + cmds: + - python -m build --wheel + + ### Translation related + + babel-new: + desc: Create a new translation for a locale + cmds: + - task: babel-extract + - pybabel init --input-file=translations/messages.pot --output-dir=translations --locale="{{ .CLI_ARGS }}" + + babel-extract: + desc: Update pot file from source + cmds: + - pybabel extract --mapping-file=babel.cfg --output-file=translations/messages.pot --msgid-bugs-address=i18n@octoprint.org --copyright-holder="The OctoPrint Project" . + + babel-update: + desc: Update translation files from pot file + cmds: + - for: + var: LOCALES + cmd: pybabel update --input-file=translations/messages.pot --output-dir=translations --locale={{ .ITEM }} + + babel-refresh: + desc: Update translation files from source + cmds: + - task: babel-extract + - task: babel-update + + babel-compile: + desc: Compile translation files + cmds: + - pybabel compile --directory=translations + + babel-bundle: + desc: Bundle translations + preconditions: + - test -d {{ .TRANSLATIONS }} + cmds: + - for: + var: LOCALES + cmd: | + locale="{{ .ITEM }}" + source="translations/${locale}" + target="{{ .TRANSLATIONS }}/${locale}" + + [ ! -d "${target}" ] || rm -r "${target}" + + echo "Copying translations for locale ${locale} from ${source} to ${target}..." + cp -r "${source}" "${target}" diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index 7c4dd76f..72525490 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -40,6 +40,9 @@ def get_blueprint_kwargs(self): def is_blueprint_protected(self): return self._plugin.is_blueprint_protected() + def is_blueprint_csrf_protected(self): + return True + def get_blueprint_api_prefixes(self): return self._plugin.get_blueprint_api_prefixes() @@ -70,8 +73,6 @@ def on_after_startup(self): self._settings.get_all_data() ) ) - self._plugin.patchCommJobReader() - self._plugin.patchComms() self._plugin.start() # It's possible to miss events or for some weirdness to occur in conditionals. Adding a watchdog @@ -122,6 +123,9 @@ def get_template_vars(self): def get_template_configs(self): return TEMPLATES + def is_template_autoescaped(self): + return True + # -------------------- End TemplatePlugin ---------------- # -------------------- Begin AssetPlugin ---------------- @@ -144,6 +148,30 @@ def resume_action_handler(self, comm, line, action, *args, **kwargs): return self._plugin.resume_action() + def gcode_sending_handler( + self, + comm_instance, + phase, + cmd, + cmd_type, + gcode, + subcode=None, + tags=None, + *args, + **kwargs, + ): + return self._plugin.gcode_sending_handler( + comm_instance, + phase, + cmd, + cmd_type, + gcode, + subcode=subcode, + tags=tags, + *args, + **kwargs, + ) + def support_gjob_format(*args, **kwargs): return dict(machinecode=dict(gjob=["gjob"])) @@ -161,5 +189,6 @@ def __plugin_load__(): "octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information, "octoprint.access.permissions": __plugin_implementation__.add_permissions, "octoprint.comm.protocol.action": __plugin_implementation__.resume_action_handler, + "octoprint.comm.protocol.gcode.sending": __plugin_implementation__.gcode_sending_handler, "octoprint.filemanager.extension_tree": __plugin_implementation__.support_gjob_format, } diff --git a/continuousprint/api.py b/continuousprint/api.py index 62f98e6a..028d7231 100644 --- a/continuousprint/api.py +++ b/continuousprint/api.py @@ -8,7 +8,6 @@ import json from .storage import queries from .storage.database import DEFAULT_QUEUE -from .data import CustomEvents from .driver import Action as DA from abc import ABC, abstractmethod @@ -97,6 +96,9 @@ def cpq_permission_wrapper(*args, **kwargs): class ContinuousPrintAPI(ABC, octoprint.plugin.BlueprintPlugin): + def is_blueprint_csrf_protected(self): + return True + @abstractmethod def _update(self, a: DA): pass @@ -319,7 +321,7 @@ def get_queues(self): @cpq_permission(Permission.EDITQUEUES) def edit_queues(self): queues = json.loads(flask.request.form.get("json")) - (absent_names, added) = queries.assignQueues(queues) + absent_names, added = queries.assignQueues(queues) self._commit_queues(added, absent_names) return json.dumps("OK") diff --git a/continuousprint/api_test.py b/continuousprint/api_test.py index 84c86c60..9f49c73c 100644 --- a/continuousprint/api_test.py +++ b/continuousprint/api_test.py @@ -3,7 +3,7 @@ import logging from .driver import Action as DA from unittest.mock import patch, MagicMock, call, PropertyMock -import imp +import importlib from flask import Flask from .api import Permission, cpq_permission import continuousprint.api @@ -44,7 +44,7 @@ def setUp(self): # , plugin, restrict): # octoprint internal state. def kill_patches(): patch.stopall() - imp.reload(continuousprint.api) + importlib.reload(continuousprint.api) self.addCleanup(kill_patches) patch( @@ -52,7 +52,7 @@ def kill_patches(): lambda x: x, ).start() - imp.reload(continuousprint.api) + importlib.reload(continuousprint.api) self.perm = patch("continuousprint.api.Permissions").start() patch.object( continuousprint.api.ContinuousPrintAPI, "__abstractmethods__", set() @@ -94,8 +94,8 @@ def test_role_access_denied(self): num_handlers_tested = len(set([tc[1] for tc in testcases])) handlers = [ f - for f in dir(self.api) - if hasattr(getattr(self.api, f), "_blueprint_rules") + for f in dir(type(self.api)) + if hasattr(getattr(type(self.api), f), "_blueprint_rules") ] self.assertEqual(num_handlers_tested, len(handlers)) diff --git a/continuousprint/data/data_test.py b/continuousprint/data/data_test.py index 4083c4d8..51199aa0 100644 --- a/continuousprint/data/data_test.py +++ b/continuousprint/data/data_test.py @@ -46,7 +46,7 @@ def test_referential_integrity(self): self.assertNotEqual(GCODE_SCRIPTS.get(v["defaults"][s]), None) -def test_preprocessor(name): +def _preprocessor_case(name): def preprocessor_decorator(func): def testcase(self): def runInterp(symtable): @@ -81,17 +81,17 @@ def test_has_all_fields(self): sorted(["body", "name"]), ) - @test_preprocessor("If the bed temperature is >40C") + @_preprocessor_case("If the bed temperature is >40C") def test_bed_temp(self, pp): self.assertEqual(pp(dict(current=dict(bed_temp=40)))[0], False) self.assertEqual(pp(dict(current=dict(bed_temp=41)))[0], True) - @test_preprocessor('If print filename ends in "_special.gcode"') + @_preprocessor_case('If print filename ends in "_special.gcode"') def test_filename_special(self, pp): self.assertEqual(pp(dict(current=dict(path="foo.gcode")))[0], False) self.assertEqual(pp(dict(current=dict(path="foo_special.gcode")))[0], True) - @test_preprocessor("If print will be at least 10mm high") + @_preprocessor_case("If print will be at least 10mm high") def test_print_height(self, pp): self.assertEqual( pp(dict(metadata=dict(analysis=dict(dimensions=dict(height=9)))))[0], False @@ -100,7 +100,7 @@ def test_print_height(self, pp): pp(dict(metadata=dict(analysis=dict(dimensions=dict(height=10)))))[0], True ) - @test_preprocessor("If print takes on average over an hour to complete") + @_preprocessor_case("If print takes on average over an hour to complete") def test_avg_print_time(self, pp): self.assertEqual( pp( @@ -121,7 +121,7 @@ def test_avg_print_time(self, pp): True, ) - @test_preprocessor("If print has failed more than 10% of the time") + @_preprocessor_case("If print has failed more than 10% of the time") def test_failure_rate(self, pp): history = [dict(success=False)] for i in range(10): @@ -130,19 +130,19 @@ def test_failure_rate(self, pp): history.append(dict(success=False)) self.assertEqual(pp(dict(metadata=dict(history=history)))[0], True) - @test_preprocessor("Also notify of bed temperature") + @_preprocessor_case("Also notify of bed temperature") def test_notify(self, pp): result, stdout = pp(dict(current=dict(bed_temp=1))) stdout.seek(0) self.assertEqual(stdout.read(), "Preprocessor says the bed temperature is 1\n") - @test_preprocessor("Error and pause if bed is >60C") + @_preprocessor_case("Error and pause if bed is >60C") def test_error(self, pp): self.assertEqual(pp(dict(current=dict(bed_temp=1)))[0], True) with self.assertRaisesRegex(Exception, "600C"): pp(dict(current=dict(bed_temp=600))) - @test_preprocessor("If starting from idle (first run, or ran finished script)") + @_preprocessor_case("If starting from idle (first run, or ran finished script)") def test_from_idle(self, pp): self.assertEqual( pp( @@ -169,7 +169,7 @@ def test_from_idle(self, pp): True, ) - @test_preprocessor("If externally set variable is True") + @_preprocessor_case("If externally set variable is True") def test_extern(self, pp): self.assertEqual(pp(dict(external=dict(testval=True)))[0], True) self.assertEqual(pp(dict(external=dict(testval=False)))[0], False) diff --git a/continuousprint/driver_test.py b/continuousprint/driver_test.py index 7483b43b..b52c83d7 100644 --- a/continuousprint/driver_test.py +++ b/continuousprint/driver_test.py @@ -1,11 +1,10 @@ import unittest import datetime import time -from unittest.mock import MagicMock, ANY +from unittest.mock import MagicMock from .driver import Driver, Action as DA, Printer as DP from .data import CustomEvents import logging -import traceback # logging.basicConfig(level=logging.DEBUG) diff --git a/continuousprint/integration_test.py b/continuousprint/integration_test.py index 2f10b32c..5df918ee 100644 --- a/continuousprint/integration_test.py +++ b/continuousprint/integration_test.py @@ -1,12 +1,10 @@ import unittest -import datetime import time import tempfile -from unittest.mock import MagicMock, ANY +from unittest.mock import MagicMock from .driver import Driver, Action as DA, Printer as DP from pathlib import Path import logging -import traceback from .storage.database_test import DBTest from .storage.database import DEFAULT_QUEUE, MODELS, populate_queues from .storage import queries @@ -362,7 +360,7 @@ def onupdate(): profile = dict(name="profile") lq = LANQueue( "LAN", - f"peer{i}:{12345+i}", + f"peer{i}:{12345 + i}", logging.getLogger(f"peer{i}:LAN"), Strategy.IN_ORDER, onupdate, @@ -403,8 +401,8 @@ def onupdate(): def test_ordered_acquisition(self): logging.info("============ BEGIN TEST ===========") self.assertEqual(len(self.peers), 2) - (d1, _, lq1, db1) = self.peers[0] - (d2, _, lq2, db2) = self.peers[1] + d1, _, lq1, db1 = self.peers[0] + d2, _, lq2, db2 = self.peers[1] for name in ("j1", "j2", "j3"): lq1.lan.q.setJob( f"{name}_hash", @@ -454,8 +452,8 @@ def test_ordered_acquisition(self): self.assertEqual(d2.state.__name__, d2._state_idle.__name__) def test_non_local_edit(self): - (d1, _, lq1, db1) = self.peers[0] - (d2, _, lq2, db2) = self.peers[1] + d1, _, lq1, db1 = self.peers[0] + d2, _, lq2, db2 = self.peers[1] with tempfile.TemporaryDirectory() as tdir: (Path(tdir) / "test.gcode").touch() j = LANJobView( diff --git a/continuousprint/plugin.py b/continuousprint/plugin.py index dbf91caa..ab7c815c 100644 --- a/continuousprint/plugin.py +++ b/continuousprint/plugin.py @@ -31,7 +31,6 @@ ) from .data import ( PRINTER_PROFILES, - GCODE_SCRIPTS, Keys, TEMP_FILE_DIR, PRINT_FILE_DIR, @@ -84,6 +83,7 @@ def __init__( self._fire_event = fire_event self._exceptions = [] self._timelapse_start_ts = None + self._skip_cmd_list = set() def start(self): self._setup_thirdparty_plugin_integration() @@ -97,6 +97,7 @@ def _on_queue_update(self, q, now=time.time()): self._sync_state() def _on_settings_updated(self): + self._refresh_skip_gcode_commands() self.d.set_retry_on_pause( self._get_key(Keys.RESTART_ON_PAUSE, False), int(self._get_key(Keys.RESTART_MAX_RETRIES, 0)), @@ -298,56 +299,53 @@ def _setup_thirdparty_plugin_integration(self): octoprint.events.Events, "PLUGIN__SPOOLMANAGER_SPOOL_DESELECTED", None ) - def patchCommJobReader(self): - # Patch the comms interface to allow for suppressing GCODE script events when the - # queue is running script events - try: - if self._get_key(Keys.SKIP_GCODE_COMMANDS).strip() == "": - self._logger.info( - "Skipping patch of comm._get_next_from_job; no commands configured to skip" - ) - return - self._ignore_cmd_list = set( - [ - c.split(";", 1)[0].strip().upper() - for c in self._get_key(Keys.SKIP_GCODE_COMMANDS).split("\n") - ] - ) + def _refresh_skip_gcode_commands(self): + configured_skip_commands = self._get_key(Keys.SKIP_GCODE_COMMANDS, "") + if configured_skip_commands is None: + configured_skip_commands = "" + if configured_skip_commands.strip() == "": + self._skip_cmd_list = set() + self._logger.info("No commands configured to skip") + return - self._jobCommReaderOrig = self._printer._comm._get_next_from_job - self._printer._comm._get_next_from_job = self.gatedCommJobReader - self._logger.info( - f"Patched comm._get_next_from_job; will ignore commands: {self._ignore_cmd_list}" + self._skip_cmd_list = { + c.split(";", 1)[0].strip().upper() + for c in configured_skip_commands.split("\n") + } + + def gcode_sending_handler( + self, + comm_instance, + phase, + cmd, + cmd_type, + gcode, + subcode=None, + tags=None, + *args, + **kwargs, + ): + tags = tags or set() + + # During CPQ automation states, suppress OctoPrint GCODE script lines + if "source:script" in tags and shouldBlockCoreEvents(self.d.state): + self._logger.warning( + f"Suppressing GCODE script command ({cmd}) as driver is in state {self.d.state}" ) - except Exception: - self._logger.error(traceback.format_exc()) + return (None,) - def gatedCommJobReader(self, *args, **kwargs): - # As this patches core OctoPrint functionality, we wrap *everything* - # in try/catch to ensure it continues to execute if CPQ raises an exception. - result = self._jobCommReaderOrig(*args, **kwargs) - try: - # Only mess with gcode commands of printed files, not events - if self.d.state != self.d._state_printing: - return result - - while result[0] is not None: - if type(result[0]) != str: - return result - line = result[0].strip() - if line == "": - return result - - # Normalize command, without uppercase - cmd = result[0].split(";", 1)[0].strip().upper() - if cmd not in self._ignore_cmd_list: - break - self._logger.warning(f"Skip GCODE: {result}") - result = self._jobCommReaderOrig(*args, **kwargs) - except Exception: - self._logger.error(traceback.format_exc()) - finally: - return result + # Skip configured commands from the print-file stream, only while printing + if ( + self.d.state == self.d._state_printing + and "source:file" in tags + and isinstance(cmd, str) + ): + normalized_cmd = cmd.split(";", 1)[0].strip().upper() + if normalized_cmd in self._skip_cmd_list: + self._logger.warning(f"Skip GCODE from file: {cmd}") + return (None,) + + return None def _init_fileshare(self, fs_cls=Fileshare): # Note: fileshare_dir referenced when cleaning up old files @@ -547,7 +545,7 @@ def _enqueue_analysis_backlog(self): # https://github.com/OctoPrint/OctoPrint/blob/f430257d7072a83692fc2392c683ed8c97ae47b6/src/octoprint/filemanager/__init__.py#L301 self._logger.debug("Searching files for backlogged CPQ analysis") counter = 0 - file_list = self._file_manager.list_files(destinations=FileDestinations.LOCAL)[ + file_list = self._file_manager.list_files(FileDestinations.LOCAL)[ FileDestinations.LOCAL ] for path in self._backlog_from_file_list(file_list): @@ -582,7 +580,7 @@ def _on_analysis_finished(self, entry, result): def _cleanup_fileshare(self): if not os.path.exists(self.fileshare_dir): - return n + return 0 # This cleans up all non-useful fileshare files across all network queues, so they aren't just taking up space. # First we collect all non-local queue items hosted by us - these are excluded from cleanup as someone may need to fetch them. @@ -656,7 +654,7 @@ def on_event(self, event, payload): f"Handling completed analysis for {path} - pending: {pend}" ) if pend is not None: - (path, sd, draft, profiles) = pend + path, sd, draft, profiles = pend prof = payload["result"][CPQProfileAnalysisQueue.PROFILE_KEY] if (profiles is None or profiles == []) and prof != "": profiles = [prof] @@ -854,35 +852,9 @@ def _commit_queues(self, added, removed): self.q.add(a["name"], lq) except ValueError: self._logger.error( - f"Unable to join network queue (name {qdata['name']}, addr {qdata['addr']}) due to ValueError" + f"Unable to join network queue (name {a['name']}, addr {a['addr']}) due to ValueError" ) # We trigger state update rather than returning it here, because this is called by the settings viewmodel # (not the main viewmodel that displays the queues) self._sync_state() - - def patchComms(self): - # Patch the comms interface to allow for suppressing GCODE script events when the - # qeue is running script events - try: - self._sendGcodeScriptOrig = self._printer._comm.sendGcodeScript - self._printer._comm.sendGcodeScript = self.gatedSendGcodeScript - self._logger.info("Patched sendGCodeScript") - except Exception: - self._logger.error(traceback.format_exc()) - - def gatedSendGcodeScript(self, *args, **kwargs): - # As this patches core OctoPrint functionality, we wrap *everything* - # in try/catch to ensure it continues to execute if CPQ raises an exception. - shouldCall = True - try: - if shouldBlockCoreEvents(self.d.state): - shouldCall = False - self._logger.warning( - f"Suppressing sendGcodeScript({args[0]}) as driver is in state {self.d.state}" - ) - except Exception: - self._logger.error(traceback.format_exc()) - finally: - if shouldCall: - return self._sendGcodeScriptOrig(*args, **kwargs) diff --git a/continuousprint/plugin_test.py b/continuousprint/plugin_test.py index d9ec006d..918887ad 100644 --- a/continuousprint/plugin_test.py +++ b/continuousprint/plugin_test.py @@ -4,7 +4,7 @@ from .analysis import CPQProfileAnalysisQueue from .storage.queries import getJobsAndSets from .storage.database import DEFAULT_QUEUE, ARCHIVE_QUEUE -from unittest.mock import MagicMock, patch, ANY, call, PropertyMock +from unittest.mock import MagicMock, patch, ANY, call from octoprint.filemanager.analysis import QueueEntry from .driver import Driver, Action as DA from octoprint.events import Events @@ -85,70 +85,103 @@ def testSpoolManagerFound(self): self.assertEqual(p._get_key(Keys.MATERIAL_SELECTION), True) # Spoolmanager self.assertEqual(p._get_key(Keys.RESTART_ON_PAUSE), False) # Obico - def testPatchCommJobReader(self): + def testGcodeSendingHandlerSkipsConfiguredFileCommands(self): p = setupPlugin() - gnfj = p._printer._comm._get_next_from_job - p.d = MagicMock(_state_printing="foo", state="foo") + p.d = MagicMock(state=Driver._state_printing) + p.d._state_printing = Driver._state_printing p._set_key(Keys.SKIP_GCODE_COMMANDS, "FOO 1\nBAR ; Settings comment") - p.patchCommJobReader() - - mm = MagicMock() - gnfj.side_effect = [ - (line, None, None) - for line in ( - "", - mm, - "foo 1", # Case insensitive - "BAR ; I have a comment that should be ignored ;;;", - "G0 X0", - None, - ) - ] - - # Passes whitespace lines - self.assertEqual(p._printer._comm._get_next_from_job(), ("", ANY, ANY)) - - # Passes foreign objects (e.g. for SendQueueMarker in OctoPrint) - self.assertEqual(p._printer._comm._get_next_from_job(), (mm, ANY, ANY)) + p._on_settings_updated() # Skips cmds in skip-list - self.assertEqual(p._printer._comm._get_next_from_job(), ("G0 X0", ANY, ANY)) + self.assertEqual( + p.gcode_sending_handler( + None, + "sending", + "foo 1", + None, + "FOO", + tags={"source:file"}, + ), + (None,), + ) + self.assertEqual( + p.gcode_sending_handler( + None, + "sending", + "BAR ; comment", + None, + "BAR", + tags={"source:file"}, + ), + (None,), + ) - # Stops on end of file - self.assertEqual(p._printer._comm._get_next_from_job(), (None, ANY, ANY)) + # Unlisted cmds pass through unchanged + self.assertIsNone( + p.gcode_sending_handler( + None, + "sending", + "G0 X0", + None, + "G0", + tags={"source:file"}, + ) + ) - # Test exception inside loop returns a decent result - gnfj.side_effect = [("foo 1", None, None), Exception("Testing exception")] - self.assertEqual(p._printer._comm._get_next_from_job(), ("foo 1", ANY, ANY)) + # Skip-list only applies to file cmds + self.assertIsNone( + p.gcode_sending_handler( + None, + "sending", + "foo 1", + None, + "FOO", + tags={"source:script"}, + ) + ) # Ignored when not printing - p.d = MagicMock(_state_printing="foo", state="bar") - gnfj.side_effect = [("foo 1", None, None)] - self.assertEqual(p._printer._comm._get_next_from_job(), ("foo 1", ANY, ANY)) + p.d.state = Driver._state_activating + self.assertIsNone( + p.gcode_sending_handler( + None, + "sending", + "foo 1", + None, + "FOO", + tags={"source:file"}, + ) + ) - def testPatchComms(self): + def testGcodeSendingHandlerSuppressesGcodeScriptCommands(self): p = setupPlugin() - sgs = p._printer._comm.sendGcodeScript - p.patchComms() # Suppress states in which we're running user configured event scripts - sgs.reset_mock() p.d = MagicMock(state=Driver._state_activating) - p._printer._comm.sendGcodeScript("FOO") - sgs.assert_not_called() + self.assertEqual( + p.gcode_sending_handler( + None, + "sending", + "M117 hello", + None, + "M117", + tags={"source:script"}, + ), + (None,), + ) # Pass through states where default OctoPrint behavior should be obeyed - sgs.reset_mock() p.d = MagicMock(state=Driver._state_printing) - p._printer._comm.sendGcodeScript("FOO") - sgs.assert_called() - - # Passthru still happens despite exceptions - sgs.reset_mock() - p.d = MagicMock() - type(p.d).state = PropertyMock(side_effect=Exception("testing error")) - p._printer._comm.sendGcodeScript("FOO") - sgs.assert_called() + self.assertIsNone( + p.gcode_sending_handler( + None, + "sending", + "M117 hello", + None, + "M117", + tags={"source:script"}, + ) + ) def testDBNew(self): p = setupPlugin() diff --git a/continuousprint/queues/abstract_test.py b/continuousprint/queues/abstract_test.py index 979cdb78..e15b5a23 100644 --- a/continuousprint/queues/abstract_test.py +++ b/continuousprint/queues/abstract_test.py @@ -5,7 +5,7 @@ class DummyQueue: name = "foo" -def testJob(inst): +def make_test_job(inst): s = SetView() s.id = inst s.path = f"set{inst}.gcode" @@ -60,10 +60,12 @@ def assertSetsEqual(self, s1, s2): class AbstractQueueTests(JobEqualityTests): def setUp(self): - raise NotImplementedError("Must create queue as self.q with testJob() inserted") + raise NotImplementedError( + "Must create queue as self.q with make_test_job() inserted" + ) def test_acquire_get_release(self): - j = testJob(0) + j = make_test_job(0) self.assertEqual(self.q.acquire(), True) self.assertEqual(self.q.get_job().acquired, True) self.assertJobsEqual(self.q.get_job(), j, ignore=["acquired"]) @@ -99,7 +101,7 @@ class EditableQueueTests(JobEqualityTests): def setUp(self): raise NotImplementedError( - "Must create queue as self.q with testJob() inserted (inst=0..3)" + "Must create queue as self.q with make_test_job() inserted (inst=0..3)" ) def test_mv_job_exchange(self): @@ -128,8 +130,8 @@ def test_edit_job_then_decrement_persists_changes(self): self.assertEqual(len(self.q.as_dict()["jobs"][0]["sets"]), 1) # Edit the acquired job, adding a new set - newsets = [testJob(0).sets[0].as_dict()] # Same as existing - newsets.append(testJob(100).sets[0].as_dict()) # New set + newsets = [make_test_job(0).sets[0].as_dict()] # Same as existing + newsets.append(make_test_job(100).sets[0].as_dict()) # New set self.q.edit_job(self.jids[0], dict(sets=newsets)) # Value after decrement should be consistent, i.e. not regress to prior acquired-job value @@ -137,15 +139,15 @@ def test_edit_job_then_decrement_persists_changes(self): self.assertEqual(len(self.q.as_dict()["jobs"][0]["sets"]), 2) def test_get_job_view(self): - self.assertJobsEqual(self.q.get_job_view(self.jids[0]), testJob(0)) + self.assertJobsEqual(self.q.get_job_view(self.jids[0]), make_test_job(0)) def test_import_job_from_view(self): - j = testJob(10) + j = make_test_job(10) jid = self.q.import_job_from_view(j) self.assertJobsEqual(self.q.get_job_view(jid), j) def test_import_job_from_view_persists_completion_and_remaining(self): - j = testJob(10) + j = make_test_job(10) j.sets[0].completed = 3 j.sets[0].remaining = 5 jid = self.q.import_job_from_view(j) diff --git a/continuousprint/queues/lan.py b/continuousprint/queues/lan.py index 0a756d48..0762c81c 100644 --- a/continuousprint/queues/lan.py +++ b/continuousprint/queues/lan.py @@ -1,8 +1,7 @@ import uuid from typing import Optional -from bisect import bisect_left from peerprint.lan_queue import LANPrintQueue, ChangeType -from ..storage.lan import LANJobView, LANSetView +from ..storage.lan import LANJobView from ..storage.database import JobView, SetView from pathlib import Path from .abstract import AbstractEditableQueue, QueueData, Strategy @@ -103,7 +102,7 @@ def get_gjob_dirpath(self, peer, hash_): # -------- Wrappers around LANQueue to add/remove metadata ------ def _annotate_job(self, peer_and_manifest, acquired_by): - (peer, manifest) = peer_and_manifest + peer, manifest = peer_and_manifest m = dict(**manifest) m["peer_"] = peer m["acquired"] = True if acquired_by is not None else False @@ -165,7 +164,7 @@ def _peek(self): def acquire(self) -> bool: if self.lan is None or self.lan.q is None: return False - (job, s) = self._peek() + job, s = self._peek() if job is not None and s is not None: if self.lan.q.acquireJob(job.id): self._logger.debug(f"acquire() candidate:\n{job}\n{s}") diff --git a/continuousprint/queues/lan_test.py b/continuousprint/queues/lan_test.py index 4bea19c0..c9fd59b8 100644 --- a/continuousprint/queues/lan_test.py +++ b/continuousprint/queues/lan_test.py @@ -1,13 +1,11 @@ import unittest import logging -import tempfile -from datetime import datetime from unittest.mock import MagicMock from .abstract import Strategy from .abstract_test import ( AbstractQueueTests, EditableQueueTests, - testJob as makeAbstractTestJob, + make_test_job as makeAbstractTestJob, ) from .lan import LANQueue, ValidationError from ..storage.database import JobView, SetView diff --git a/continuousprint/queues/local_test.py b/continuousprint/queues/local_test.py index 1153b40f..771556ce 100644 --- a/continuousprint/queues/local_test.py +++ b/continuousprint/queues/local_test.py @@ -1,5 +1,4 @@ import unittest -import logging from ..storage.database_test import QueuesDBTest from ..storage import queries from ..storage.lan import LANJobView @@ -9,10 +8,10 @@ from .abstract_test import ( AbstractQueueTests, EditableQueueTests, - testJob as makeAbstractTestJob, + make_test_job as makeAbstractTestJob, ) from .local import LocalQueue -from dataclasses import dataclass, asdict +from dataclasses import asdict # logging.basicConfig(level=logging.DEBUG) diff --git a/continuousprint/queues/multi_test.py b/continuousprint/queues/multi_test.py index df910b4e..0cb4e0e4 100644 --- a/continuousprint/queues/multi_test.py +++ b/continuousprint/queues/multi_test.py @@ -1,5 +1,4 @@ import unittest -import logging from unittest.mock import MagicMock from .abstract import Strategy from .multi import MultiQueue diff --git a/continuousprint/script_runner.py b/continuousprint/script_runner.py index 007efe34..1c5224cf 100644 --- a/continuousprint/script_runner.py +++ b/continuousprint/script_runner.py @@ -1,4 +1,3 @@ -import time from io import BytesIO from pathlib import Path from octoprint.filemanager.util import StreamWrapper diff --git a/continuousprint/script_runner_test.py b/continuousprint/script_runner_test.py index 49f8eb78..ddeccf99 100644 --- a/continuousprint/script_runner_test.py +++ b/continuousprint/script_runner_test.py @@ -2,7 +2,6 @@ from dataclasses import dataclass from io import StringIO from octoprint.printer import InvalidFileLocation, InvalidFileType -from octoprint.filemanager.destinations import FileDestinations from octoprint.slicing.exceptions import SlicingException from collections import namedtuple from unittest.mock import MagicMock, ANY, patch diff --git a/continuousprint/scripts/test_extract_profile.py b/continuousprint/scripts/test_extract_profile.py index 11c56368..8ee040c8 100644 --- a/continuousprint/scripts/test_extract_profile.py +++ b/continuousprint/scripts/test_extract_profile.py @@ -1,6 +1,6 @@ import unittest import tempfile -from .extract_profile import get_profile, get_header, get_footer +from continuousprint.scripts.extract_profile import get_profile, get_header, get_footer class TestProfileInference(unittest.TestCase): diff --git a/continuousprint/static/js/continuousprint_queue.js b/continuousprint/static/js/continuousprint_queue.js index 7c09ac9f..0bc1c49e 100644 --- a/continuousprint/static/js/continuousprint_queue.js +++ b/continuousprint/static/js/continuousprint_queue.js @@ -217,6 +217,7 @@ function CPQueue(data, api, files, profile, materials, stats_dimensions=CP_STATS new PNotify({ title: 'Continuous Print', text: `Error(s) during export: \n - ${result.errors.join('\n - ')}`, + text_escape: true, type: 'error', hide: false, buttons: {closer: true, sticker: false} @@ -226,6 +227,7 @@ function CPQueue(data, api, files, profile, materials, stats_dimensions=CP_STATS new PNotify({ title: 'Continuous Print', text: `Exported jobs to 'files' panel: \n - ${result.paths.join('\n - ')}`, + text_escape: true, type: 'success', hide: true, buttons: {closer: true, sticker: false} diff --git a/continuousprint/static/js/continuousprint_settings.js b/continuousprint/static/js/continuousprint_settings.js index 87267c04..07569e55 100644 --- a/continuousprint/static/js/continuousprint_settings.js +++ b/continuousprint/static/js/continuousprint_settings.js @@ -27,6 +27,7 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_s new PNotify({ title: `Continuous Print Settings (Error ${code})`, text: reason, + text_escape: true, type: 'error', hide: true, buttons: {closer: true, sticker: false}, diff --git a/continuousprint/static/js/continuousprint_viewmodel.js b/continuousprint/static/js/continuousprint_viewmodel.js index 9037770d..1ed664cd 100644 --- a/continuousprint/static/js/continuousprint_viewmodel.js +++ b/continuousprint/static/js/continuousprint_viewmodel.js @@ -60,6 +60,7 @@ function CPViewModel(parameters) { new PNotify({ title: `Continuous Print API (Error ${code})`, text: reason, + text_escape: true, type: 'error', hide: true, buttons: {closer: true, sticker: false}, @@ -341,6 +342,7 @@ function CPViewModel(parameters) { new PNotify({ title: 'Continuous Print', text: data.msg, + text_escape: true, type: theme, hide: (theme !== 'danger'), buttons: {closer: true, sticker: false} diff --git a/continuousprint/storage/database.py b/continuousprint/storage/database.py index c27e3b60..9538420f 100644 --- a/continuousprint/storage/database.py +++ b/continuousprint/storage/database.py @@ -7,26 +7,15 @@ ForeignKeyField, BooleanField, FloatField, - DateField, - TimeField, TextField, - CompositeKey, - JOIN, Check, ) from playhouse.migrate import SqliteMigrator, migrate from ..data import CustomEvents, PREPROCESSORS -from collections import defaultdict import datetime -from enum import IntEnum, auto -import sys import logging -import inspect import os -import yaml -import time - logging.getLogger("peewee").setLevel(logging.INFO) diff --git a/continuousprint/storage/queries.py b/continuousprint/storage/queries.py index 1874d3b4..286e377d 100644 --- a/continuousprint/storage/queries.py +++ b/continuousprint/storage/queries.py @@ -20,7 +20,6 @@ ) from ..data import CustomEvents - MAX_COUNT = 999999 @@ -113,7 +112,7 @@ def assignQueues(queues): qq_names = set([q.name for q in qq]) absent = [(q.id, q.name) for q in qq if q.name not in names] if len(absent) > 0: - (absent_ids, absent_names) = zip(*absent) + absent_ids, absent_names = zip(*absent) Queue.delete().where(Queue.id.in_(absent_ids)).execute() else: absent_names = [] @@ -490,9 +489,9 @@ def getAutomation(): events[e.name].append( dict( script=e.script.name, - preprocessor=e.preprocessor.name - if e.preprocessor is not None - else None, + preprocessor=( + e.preprocessor.name if e.preprocessor is not None else None + ), ) ) diff --git a/continuousprint/thirdparty/spoolmanager.py b/continuousprint/thirdparty/spoolmanager.py index 6fb87537..59710b18 100644 --- a/continuousprint/thirdparty/spoolmanager.py +++ b/continuousprint/thirdparty/spoolmanager.py @@ -15,9 +15,11 @@ def get_materials(self): try: materials = self._impl.api_getSelectedSpoolInformations() materials = [ - f"{m['material']}_{m['colorName']}_{m['color']}" - if m is not None - else None + ( + f"{m['material']}_{m['colorName']}_{m['color']}" + if m is not None + else None + ) for m in materials ] return materials diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index 44a73eeb..00000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -pre-commit # For running automated precommit scripts -mkdocs-material # Theme for documentation -mkdocs # Documentation library -pymdown-extensions # Fancy extensions for documentation diff --git a/docs/contributing.md b/docs/contributing.md index f2c90aea..9a48ace2 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -68,7 +68,7 @@ It is recommended to [fork](https://docs.github.com/en/get-started/quickstart/fo ``` git clone https://github.com/smartin015/continuousprint.git cd continuousprint -pip install -r dev-dependencies.txt +pip install -e .[develop] pre-commit install ``` @@ -116,7 +116,7 @@ Enable debug-level messages from ContinuousPrint by going into `Settings > Loggi Continuous Print uses [mkdocs](https://www.mkdocs.org/) to generate web documentation. All documentation lives in `docs/`. ```shell -pip install mkdocs mkdocs-material +pip install -e .[develop] ``` if you installed the dev tools (step 2) you can run `mkdocs serve` from the root of the repository to see doc edits live at [http://localhost:8000](http://localhost:8000). @@ -128,7 +128,7 @@ When you've made your changes, it's important to test for regressions. Run python tests with this command: ``` -python3 -m unittest *_test.py +python -m pytest ``` Frontend unit tests require some additional setup (make sure [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#debian-stable) and its dependencies are installed): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..414713ce --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[build-system] +requires = [ + "setuptools>=68", +] +build-backend = "setuptools.build_meta" + +[project] +name = "continuousprint" +version = "2.4.1" +description = "Allows a print to be restarted after it has been completed. Use with a Gcode at the end to sweep the old print off the bed in preparation for the new." +requires-python = ">=3.7,<4" +dependencies = [ + "peewee<4", + "peerprint==0.1.0", + "asteval==0.9.28", +] +dynamic = [ + "license", +] + +[[project.authors]] +name = "Scott Martin, formerly Louis Sarwal & Paul Goddard" +email = "smartin015+oprint@gmail.com" + +[project.entry-points."octoprint.plugin"] +continuousprint = "continuousprint" + +[project.urls] +Homepage = "https://github.com/smartin015/continuousprint" + +[project.optional-dependencies] +develop = [ + "go-task-bin", + "pre-commit", + "mkdocs", + "mkdocs-material", + "pymdown-extensions", +] + +[project.readme] +file = "README.md" +content-type = "text/markdown" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = [ + "continuousprint", + "continuousprint.*", +] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index a1dc4637..00000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -### -# This file is only here to make sure that something like -# -# pip install -e . -# -# works as expected. Requirements can be found in setup.py. -### - -. diff --git a/setup.py b/setup.py index bc73906a..594342ee 100644 --- a/setup.py +++ b/setup.py @@ -1,101 +1,4 @@ -# coding=utf-8 +import setuptools -######################################################################################################################## -### Do not forget to adjust the following variables to your own plugin. - -# The plugin's identifier, has to be unique -plugin_identifier = "continuousprint" - -# The plugin's python package, should be "octoprint_", has to be unique -plugin_package = "continuousprint" - -# The plugin's human readable name. Can be overwritten within OctoPrint's internal data via __plugin_name__ in the -# plugin module -plugin_name = "continuousprint" - -# The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module -plugin_version = "2.4.1" - -# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin -# module -plugin_description = """Allows a print to be restarted after it has been completed. Use with a Gcode at the end to sweep the old print off the bed in preparation for the new.""" - -# The plugin's author. Can be overwritten within OctoPrint's internal data via __plugin_author__ in the plugin module -plugin_author = "Scott Martin, formerly Louis Sarwal & Paul Goddard" - -# The plugin's author's mail address. -plugin_author_email = "smartin015+oprint@gmail.com" - -# The plugin's homepage URL. Can be overwritten within OctoPrint's internal data via __plugin_url__ in the plugin module -plugin_url = "https://github.com/smartin015/continuousprint" - -# The plugin's license. Can be overwritten within OctoPrint's internal data via __plugin_license__ in the plugin module -plugin_license = "AGPLv3" - -# Any additional requirements besides OctoPrint should be listed here -plugin_requires = ["peewee<4", "peerprint==0.1.0", "asteval==0.9.28"] - -### -------------------------------------------------------------------------------------------------------------------- -### More advanced options that you usually shouldn't have to touch follow after this point -### -------------------------------------------------------------------------------------------------------------------- - -# Additional package data to install for this plugin. The subfolders "templates", "static" and "translations" will -# already be installed automatically if they exist. Note that if you add something here you'll also need to update -# MANIFEST.in to match to ensure that python setup.py sdist produces a source distribution that contains all your -# files. This is sadly due to how python's setup.py works, see also http://stackoverflow.com/a/14159430/2028598 -plugin_additional_data = ["continuousprint/data"] - -# Any additional python packages you need to install with your plugin that are not contained in .* -plugin_additional_packages = [] - -# Any python packages within .* you do NOT want to install with your plugin -plugin_ignored_packages = [] - -# Additional parameters for the call to setuptools.setup. If your plugin wants to register additional entry points, -# define dependency links or other things like that, this is the place to go. Will be merged recursively with the -# default setup parameters as provided by octoprint_setuptools.create_plugin_setup_parameters using -# octoprint.util.dict_merge. -# -# Example: -# plugin_requires = ["someDependency==dev"] -# additional_setup_parameters = {"dependency_links": ["https://github.com/someUser/someRepo/archive/master.zip#egg=someDependency-dev"]} -additional_setup_parameters = {} - -######################################################################################################################## - -from setuptools import setup -import os - -try: - import octoprint_setuptools -except ImportError: - print( - "Could not import OctoPrint's setuptools, are you sure you are running that under " - "the same python installation that OctoPrint is installed under?" - ) - import sys - - sys.exit(-1) - -setup_parameters = octoprint_setuptools.create_plugin_setup_parameters( - identifier=plugin_identifier, - package=plugin_package, - name=plugin_name, - version=plugin_version, - description=plugin_description, - author=plugin_author, - mail=plugin_author_email, - url=plugin_url, - license=plugin_license, - requires=plugin_requires, - additional_packages=plugin_additional_packages, - ignored_packages=plugin_ignored_packages, - additional_data=plugin_additional_data, -) - -if len(additional_setup_parameters): - from octoprint.util import dict_merge - - setup_parameters = dict_merge(setup_parameters, additional_setup_parameters) - -setup(**setup_parameters) +# we define the license string like this to be backwards compatible to setuptools<77 +setuptools.setup(license="AGPL-3.0-or-later")