From f2eeb5038104598f6aff7d79727a8b1079919c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Thu, 19 Mar 2015 20:58:24 +0100 Subject: [PATCH] Lots of documentation for slicing related things and some refactorings --- docs/modules/index.rst | 1 + docs/modules/slicing.rst | 13 + src/octoprint/filemanager/__init__.py | 23 +- src/octoprint/filemanager/analysis.py | 93 +++++++ src/octoprint/plugin/types.py | 83 +++++-- src/octoprint/plugins/cura/__init__.py | 21 +- src/octoprint/server/api/files.py | 72 +++--- src/octoprint/server/api/slicing.py | 81 +++--- src/octoprint/slicing/__init__.py | 329 ++++++++++++++++++++++--- src/octoprint/slicing/exceptions.py | 79 ++++++ 10 files changed, 655 insertions(+), 140 deletions(-) create mode 100644 docs/modules/slicing.rst diff --git a/docs/modules/index.rst b/docs/modules/index.rst index e9ef4825..29ad0783 100644 --- a/docs/modules/index.rst +++ b/docs/modules/index.rst @@ -11,4 +11,5 @@ Internal Modules plugin.rst printer.rst settings.rst + slicing.rst util.rst diff --git a/docs/modules/slicing.rst b/docs/modules/slicing.rst new file mode 100644 index 00000000..bf27d486 --- /dev/null +++ b/docs/modules/slicing.rst @@ -0,0 +1,13 @@ +.. _sec-modules-slicing: + +octoprint.slicing +----------------- + +.. automodule:: octoprint.slicing + +.. _sec-modules-slicing-exceptions: + +octoprint.slicing.exceptions +---------------------------- + +.. automodule:: octoprint.slicing.exceptions \ No newline at end of file diff --git a/src/octoprint/filemanager/__init__.py b/src/octoprint/filemanager/__init__.py index dfab1da8..e4a646ff 100644 --- a/src/octoprint/filemanager/__init__.py +++ b/src/octoprint/filemanager/__init__.py @@ -232,18 +232,17 @@ class FileManager(object): self._slicing_jobs[dest_job_key] = self._slicing_jobs[source_job_key] = (slicer_name, absolute_source_path, temp_path) args = (source_location, source_path, temp_path, dest_location, dest_path, start_time, printer_profile_id, callback, callback_args) - return self._slicing_manager.slice( - slicer_name, - absolute_source_path, - temp_path, - profile, - stlProcessed, - position=position, - callback_args=args, - overrides=overrides, - printer_profile_id=printer_profile_id, - on_progress=self.on_slicing_progress, - on_progress_args=(slicer_name, source_location, source_path, dest_location, dest_path)) + self._slicing_manager.slice(slicer_name, + absolute_source_path, + temp_path, + profile, + stlProcessed, + position=position, + callback_args=args, + overrides=overrides, + printer_profile_id=printer_profile_id, + on_progress=self.on_slicing_progress, + on_progress_args=(slicer_name, source_location, source_path, dest_location, dest_path)) def on_slicing_progress(self, slicer, source_location, source_path, dest_location, dest_path, _progress=None): if not _progress: diff --git a/src/octoprint/filemanager/analysis.py b/src/octoprint/filemanager/analysis.py index f746c228..ca7edabb 100644 --- a/src/octoprint/filemanager/analysis.py +++ b/src/octoprint/filemanager/analysis.py @@ -18,11 +18,40 @@ import octoprint.util.gcodeInterpreter as gcodeInterpreter class QueueEntry(collections.namedtuple("QueueEntry", "path, type, location, absolute_path, printer_profile")): + """ + A :class:`QueueEntry` for processing through the :class:`AnalysisQueue`. Wraps the entry's properties necessary + for processing. + + Arguments: + path (str): Storage location specific path to the file to analyze. + type (str): Type of file to analyze, necessary to map to the correct :class:`AbstractAnalysisQueue` sub class. + At the moment, only ``gcode`` is supported here. + location (str): Location the file is located on. + absolute_path (str): Absolute path on disk through which to access the file. + printer_profile (PrinterProfile): :class:`PrinterProfile` which to use for analysis. + """ + def __str__(self): return "{location}:{path}".format(location=self.location, path=self.path) class AnalysisQueue(object): + """ + OctoPrint's :class:`AnalysisQueue` can manage various :class:`AbstractAnalysisQueue` implementations, mapped + by their machine code type. + + At the moment, only the analysis of GCODE files for 3D printing is supported, through :class:`GcodeAnalysisQueue`. + + By invoking :meth:`register_finish_callback` it is possible to register oneself as a callback to be invoked each + time the analysis of a queue entry finishes. The call parameters will be the finished queue entry as the first + and the analysis result as the second parameter. It is also possible to remove the registration again by invoking + :meth:`unregister_finish_callback`. + + :meth:`enqueue` allows enqueuing :class:`QueueEntry` instances to analyze. If the :attr:`QueueEntry.type` is unknown + (no specific child class of :class:`AbstractAnalysisQueue` is registered for it), nothing will happen. Otherwise the + entry will be enqueued with the type specific analysis queue. + """ + def __init__(self): self._logger = logging.getLogger(__name__) self._callbacks = [] @@ -56,6 +85,21 @@ class AnalysisQueue(object): eventManager().fire(Events.METADATA_ANALYSIS_FINISHED, {"file": entry.path, "result": result}) class AbstractAnalysisQueue(object): + """ + The :class:`AbstractAnalysisQueue` is the parent class of all specific analysis queues such as the + :class:`GcodeAnalysisQueue`. It offers methods to enqueue new entries to analyze and pausing and resuming analysis + processing. + + Arguments: + finished_callback (callable): Callback that will be called upon finishing analysis of an entry in the queue. + The callback will be called with the analyzed entry as the first argument and the analysis result as + returned from the queue implementation as the second parameter. + + .. automethod:: _do_analysis + + .. automethod:: _do_abort + """ + def __init__(self, finished_callback): self._logger = logging.getLogger(__name__) @@ -75,6 +119,18 @@ class AbstractAnalysisQueue(object): self._worker.start() def enqueue(self, entry, high_priority=False): + """ + Enqueues an ``entry`` for analysis by the queue. + + If ``high_priority`` is True (defaults to False), the entry will be prioritized and hence processed before + other entries in the queue with normal priority. + + Arguments: + entry (QueueEntry): The :class:`QueueEntry` to analyze. + high_priority (boolean): Whether to process the provided entry with high priority (True) or not + (False, default) + """ + if high_priority: self._logger.debug("Adding entry {entry} to analysis queue with high priority".format(entry=entry)) prio = 0 @@ -85,6 +141,10 @@ class AbstractAnalysisQueue(object): self._queue.put((prio, entry)) def pause(self): + """ + Pauses processing of the queue, e.g. when a print is active. + """ + self._logger.debug("Pausing analysis") self._active.clear() if self._current is not None: @@ -92,6 +152,10 @@ class AbstractAnalysisQueue(object): self._do_abort() def resume(self): + """ + Resumes processing of the queue, e.g. when a print has finished. + """ + self._logger.debug("Resuming analyzer") self._active.set() @@ -134,13 +198,42 @@ class AbstractAnalysisQueue(object): self._current_progress = None def _do_analysis(self): + """ + Performs the actual analysis of the current entry which can be accessed via ``self._current``. Needs to be + overridden by sub classes. + + Returns: + object: The result of the analysis which will be forwarded to the ``finished_callback`` provided during + construction. + """ return None def _do_abort(self): + """ + Aborts analysis of the current entry. Needs to be overridden by sub classes. + """ pass class GcodeAnalysisQueue(AbstractAnalysisQueue): + """ + A queue to analyze GCODE files. Analysis results are :class:`dict` instances structured as follows: + + .. list-table:: + :widths: 25 70 + + - * **Key** + * **Description** + - * ``estimatedPrintTime`` + * Estimated time the file take to print, in minutes + - * ``filament`` + * Substructure describing estimated filament usage. Keys are ``tool0`` for the first extruder, ``tool1`` for + the second and so on. For each tool extruded length and volume (based on diameter) are provided. + - * ``filament.toolX.length`` + * The extruded length in mm + - * ``filament.toolX.volume`` + * The extruded volume in cm³ + """ def _do_analysis(self): try: diff --git a/src/octoprint/plugin/types.py b/src/octoprint/plugin/types.py index 83652324..63e6aa56 100644 --- a/src/octoprint/plugin/types.py +++ b/src/octoprint/plugin/types.py @@ -869,9 +869,10 @@ class EventHandlerPlugin(OctoPrintPlugin): """ Called by OctoPrint upon processing of a fired event on the platform. - :param string event: the type of event that got fired, see :ref:`the list of events ` - for possible values - :param dict payload: the payload as provided with the event + Arguments: + event (str): The type of event that got fired, see :ref:`the list of events ` + for possible values + payload (dict): The payload as provided with the event """ pass @@ -903,10 +904,14 @@ class SlicerPlugin(OctoPrintPlugin): name The human readable name of the slicer. This will be displayed to the user during slicer selection. same_device - ``True`` if the slicer runs on the same device as OctoPrint, ``False`` otherwise. Slicers running on the same - device will TODO + True if the slicer runs on the same device as OctoPrint, False otherwise. Slicers running on the same + device will not be allowed to slice while a print is running due to performance reasons. Slice requests + against slicers running on the same device will result in an error. progress_report ``True`` if the slicer can report back slicing progress to OctoPrint ``False`` otherwise. + + Returns: + dict: A dict describing the slicer as outlined above. """ return dict( type=None, @@ -917,38 +922,48 @@ class SlicerPlugin(OctoPrintPlugin): def get_slicer_default_profile(self): """ - Should return a :class:`SlicingProfile` containing the default slicing profile to use with this slicer if - no other profile has been selected. + Should return a :class:`~octoprint.slicing.SlicingProfile` containing the default slicing profile to use with + this slicer if no other profile has been selected. + + Returns: + SlicingProfile: The :class:`~octoprint.slicing.SlicingProfile` containing the default slicing profile for + this slicer. """ return None def get_slicer_profile(self, path): """ - Should return a :class:`SlicingProfile` parsed from the slicing profile stored at the indicated ``path``. + Should return a :class:`~octoprint.slicing.SlicingProfile` parsed from the slicing profile stored at the + indicated ``path``. - :param string path: the path from which to read the slicing profile + Arguments: + path (str): The absolute path from which to read the slicing profile. + + Returns: + SlicingProfile: The specified slicing profile. """ return None def save_slicer_profile(self, path, profile, allow_overwrite=True, overrides=None): """ - Should save the provided :class:`SlicingProfile` to the indicated ``path``, after applying any supplied - ``overrides``. If a profile is already saved under the indicated path and ``allow_overwrite`` is set to - ``False`` (defaults to ``True``), an ``IOError`` should be raised. + Should save the provided :class:`~octoprint.slicing.SlicingProfile` to the indicated ``path``, after applying + any supplied ``overrides``. If a profile is already saved under the indicated path and ``allow_overwrite`` is + set to False (defaults to True), an :class:`IOError` should be raised. - :param string path: the path to which to save the profile - :param SlicingProfile profile: the profile to save - :param bool allow_overwrite: whether to allow to overwrite an existing profile at the indicated path (``True``, default) - or not (``False``) - if a profile already exists on the path and this is ``False`` - and :class:`IOError` should be raised - :param dict overrides: profile overrides to apply to the ``profile`` before saving it + Arguments: + path (str): The absolute path to which to save the profile. + profile (SlicingProfile): The profile to save. + allow_overwrite (boolean): Whether to allow to overwrite an existing profile at the indicated path (True, + default) or not (False). If a profile already exists on teh path and this is False an + :class:`IOError` should be raised. + overrides (dict): Profile overrides to apply to the ``profile`` before saving it """ pass def do_slice(self, model_path, printer_profile, machinecode_path=None, profile_path=None, position=None, on_progress=None, on_progress_args=None, on_progress_kwargs=None): """ Called by OctoPrint to slice ``model_path`` for the indicated ``printer_profile``. If the ``machinecode_path`` is ``None``, - slicer implementations should generate it from the provided model_path. + slicer implementations should generate it from the provided ``model_path``. If provided, the ``profile_path`` is guaranteed by OctoPrint to be a serialized slicing profile created through the slicing plugin's own :func:`save_slicer_profile` method. @@ -973,12 +988,40 @@ class SlicerPlugin(OctoPrintPlugin): Please note that both ``on_progress_args`` and ``on_progress_kwargs`` as supplied by OctoPrint might be ``None``, so always make sure to initialize those values to sane defaults like depicted above before invoking the callback. + + In order to support external cancellation of an ongoing slicing job via :func:`cancel_slicing`, implementations + should make sure to track the started jobs via the ``machinecode_path``, if provided. + + The method should return a 2-tuple consisting of a boolean ``flag`` indicating whether the slicing job was + finished successfully (True) or not (False) and a ``result`` depending on the success of the slicing job. + + For jobs that finished successfully, ``result`` should be a :class:`dict` containing additional information + about the slicing job under the following keys: + + _analysis + Analysis result of the generated machine code as returned by the slicer itself. This should match the + data structure described for the analysis queue of the matching maching code format, e.g. + :class:`~octoprint.filemanager.analysis.GcodeAnalysisQueue` for GCODE files. + + For jobs that did not finish successfully (but not due to being cancelled!), ``result`` should be a :class:`str` + containing a human readable reason for the error. + + If the job gets cancelled, a :class:`~octoprint.slicing.SlicingCancelled` exception should be raised. + + Returns: + tuple: A 2-tuple (boolean, object) as outlined above. + + Raises: + SlicingCancelled: The slicing job was cancelled (via :meth:`cancel_slicing`). """ pass def cancel_slicing(self, machinecode_path): """ - Cancels the slicing to the indicated file + Cancels the slicing to the indicated file. + + Arguments: + machinecode_path (str): The absolute path to the machine code file to which to stop slicing to. """ pass diff --git a/src/octoprint/plugins/cura/__init__.py b/src/octoprint/plugins/cura/__init__.py index 909e4285..864de683 100644 --- a/src/octoprint/plugins/cura/__init__.py +++ b/src/octoprint/plugins/cura/__init__.py @@ -106,12 +106,15 @@ class CuraPlugin(octoprint.plugin.SlicerPlugin, from octoprint.server.api import valid_boolean_trues profile_allow_overwrite = flask.request.values["allowOverwrite"] in valid_boolean_trues - slicingManager.save_profile("cura", - profile_name, - profile_dict, - allow_overwrite=profile_allow_overwrite, - display_name=profile_display_name, - description=profile_description) + try: + slicingManager.save_profile("cura", + profile_name, + profile_dict, + allow_overwrite=profile_allow_overwrite, + display_name=profile_display_name, + description=profile_description) + except octoprint.slicing.ProfileAlreadyExists: + return flask.make_response("A profile named {profile_name} already exists for slicer cura".format(**locals()), 409) result = dict( resource=flask.url_for("api.slicingGetSlicerProfile", slicer="cura", name=profile_name, _external=True), @@ -191,6 +194,9 @@ class CuraPlugin(octoprint.plugin.SlicerPlugin, return octoprint.slicing.SlicingProfile(properties["type"], "unknown", profile_dict, display_name=display_name, description=description) def save_slicer_profile(self, path, profile, allow_overwrite=True, overrides=None): + if os.path.exists(path) and not allow_overwrite: + raise octoprint.slicing.ProfileAlreadyExists("cura", profile.name) + new_profile = Profile.merge_profile(profile.data, overrides=overrides) if profile.display_name is not None: @@ -388,9 +394,6 @@ class CuraPlugin(octoprint.plugin.SlicerPlugin, return profile_dict def _save_profile(self, path, profile, allow_overwrite=True): - if not allow_overwrite and os.path.exists(path): - raise IOError("Cannot overwrite {path}".format(path=path)) - import yaml with open(path, "wb") as f: yaml.safe_dump(profile, f, default_flow_style=False, indent=" ", allow_unicode=True) diff --git a/src/octoprint/server/api/files.py b/src/octoprint/server/api/files.py index 0f6d292d..3cdf6a23 100644 --- a/src/octoprint/server/api/files.py +++ b/src/octoprint/server/api/files.py @@ -15,6 +15,7 @@ from octoprint.server.util.flask import restricted_access, get_json_command_from from octoprint.server.api import api from octoprint.events import Events import octoprint.filemanager +import octoprint.slicing #~~ GCODE file handling @@ -297,17 +298,20 @@ def gcodeFileCommand(filename, target): printer.select_file(filenameToSelect, sd, printAfterLoading) elif command == "slice": - if "slicer" in data.keys(): - slicer = data["slicer"] - del data["slicer"] - if not slicer in slicingManager.registered_slicers: - return make_response("Slicer {slicer} is not available".format(**locals()), 400) - slicer_instance = slicingManager.get_slicer(slicer) - elif "cura" in slicingManager.registered_slicers: - slicer = "cura" - slicer_instance = slicingManager.get_slicer("cura") - else: - return make_response("Cannot slice {filename}, no slicer available".format(**locals()), 415) + try: + if "slicer" in data: + slicer = data["slicer"] + del data["slicer"] + slicer_instance = slicingManager.get_slicer(slicer) + + elif "cura" in slicingManager.registered_slicers: + slicer = "cura" + slicer_instance = slicingManager.get_slicer("cura") + + else: + return make_response("Cannot slice {filename}, no slicer available".format(**locals()), 415) + except octoprint.slicing.UnknownSlicer as e: + return make_response("Slicer {slicer} is not available".format(slicer=e.slicer), 400) if not octoprint.filemanager.valid_file_type(filename, type="stl"): return make_response("Cannot slice {filename}, not an STL file".format(**locals()), 415) @@ -316,7 +320,7 @@ def gcodeFileCommand(filename, target): # slicer runs on same device as OctoPrint, slicing while printing is hence disabled return make_response("Cannot slice on {slicer} while printing due to performance reasons".format(**locals()), 409) - if "gcode" in data.keys() and data["gcode"]: + if "gcode" in data and data["gcode"]: gcode_name = data["gcode"] del data["gcode"] else: @@ -374,31 +378,31 @@ def gcodeFileCommand(filename, target): filenameToSelect = fileManager.path_on_disk(target, gcode_name) printer.select_file(filenameToSelect, sd, print_after_slicing) - ok, result = fileManager.slice(slicer, target, filename, target, gcode_name, - profile=profile, - printer_profile_id=printerProfile, - position=position, - overrides=overrides, - callback=slicing_done, - callback_args=(target, gcode_name, select_after_slicing, print_after_slicing)) + try: + fileManager.slice(slicer, target, filename, target, gcode_name, + profile=profile, + printer_profile_id=printerProfile, + position=position, + overrides=overrides, + callback=slicing_done, + callback_args=(target, gcode_name, select_after_slicing, print_after_slicing)) + except octoprint.slicing.UnknownProfile: + return make_response("Profile {profile} doesn't exist".format(**locals()), 400) - if ok: - files = {} - location = url_for(".readGcodeFile", target=target, filename=gcode_name, _external=True) - result = { - "name": gcode_name, - "origin": FileDestinations.LOCAL, - "refs": { - "resource": location, - "download": url_for("index", _external=True) + "downloads/files/" + target + "/" + gcode_name - } + files = {} + location = url_for(".readGcodeFile", target=target, filename=gcode_name, _external=True) + result = { + "name": gcode_name, + "origin": FileDestinations.LOCAL, + "refs": { + "resource": location, + "download": url_for("index", _external=True) + "downloads/files/" + target + "/" + gcode_name } + } - r = make_response(jsonify(result), 202) - r.headers["Location"] = location - return r - else: - return make_response("Could not slice: {result}".format(result=result), 500) + r = make_response(jsonify(result), 202) + r.headers["Location"] = location + return r return NO_CONTENT diff --git a/src/octoprint/server/api/slicing.py b/src/octoprint/server/api/slicing.py index 978103a6..4ad45e06 100644 --- a/src/octoprint/server/api/slicing.py +++ b/src/octoprint/server/api/slicing.py @@ -14,7 +14,7 @@ from octoprint.server.api import api, NO_CONTENT from octoprint.settings import settings as s, valid_boolean_trues -from octoprint.slicing import SlicerNotConfigured +from octoprint.slicing import UnknownSlicer, SlicerNotConfigured, ProfileAlreadyExists, UnknownProfile @api.route("/slicing", methods=["GET"]) @@ -28,37 +28,39 @@ def slicingListAll(): result = dict() for slicer in slicers: - slicer_impl = slicingManager.get_slicer(slicer, require_configured=False) - result[slicer] = dict( - key=slicer, - displayName=slicer_impl.get_slicer_properties()["name"], - default=default_slicer == slicer, - configured = slicer_impl.is_slicer_configured(), - profiles=_getSlicingProfilesData(slicer) - ) + try: + slicer_impl = slicingManager.get_slicer(slicer, require_configured=False) + result[slicer] = dict( + key=slicer, + displayName=slicer_impl.get_slicer_properties()["name"], + default=default_slicer == slicer, + configured = slicer_impl.is_slicer_configured(), + profiles=_getSlicingProfilesData(slicer) + ) + except (UnknownSlicer, SlicerNotConfigured): + # this should never happen + pass return jsonify(result) @api.route("/slicing//profiles", methods=["GET"]) def slicingListSlicerProfiles(slicer): - if not slicer in slicingManager.registered_slicers: - return make_response("Unknown slicer {slicer}".format(**locals()), 404) - configured = False if "configured" in request.values and request.values["configured"] in valid_boolean_trues: - if not slicer in slicingManager.configured_slicers: - return make_response("Unknown slicer {slicer}".format(**locals()), 404) configured = True - return jsonify(_getSlicingProfilesData(slicer, require_configured=configured)) + try: + return jsonify(_getSlicingProfilesData(slicer, require_configured=configured)) + except (UnknownSlicer, SlicerNotConfigured): + return make_response("Unknown slicer {slicer}".format(**locals()), 404) @api.route("/slicing//profiles/", methods=["GET"]) def slicingGetSlicerProfile(slicer, name): - if not slicer in slicingManager.registered_slicers: + try: + profile = slicingManager.load_profile(slicer, name) + except UnknownSlicer: return make_response("Unknown slicer {slicer}".format(**locals()), 404) - - profile = slicingManager.load_profile(slicer, name) - if not profile: + except UnknownProfile: return make_response("Profile not found", 404) result = _getSlicingProfileData(slicer, name, profile) @@ -68,9 +70,6 @@ def slicingGetSlicerProfile(slicer, name): @api.route("/slicing//profiles/", methods=["PUT"]) @restricted_access def slicingAddSlicerProfile(slicer, name): - if not slicer in slicingManager.registered_slicers: - return make_response("Unknown slicer {slicer}".format(**locals()), 404) - if not "application/json" in request.headers["Content-Type"]: return make_response("Expected content-type JSON", 400) @@ -89,7 +88,12 @@ def slicingAddSlicerProfile(slicer, name): if "description" in json_data: description = json_data["description"] - profile = slicingManager.save_profile(slicer, name, data, display_name=display_name, description=description) + try: + profile = slicingManager.save_profile(slicer, name, data, display_name=display_name, description=description) + except UnknownSlicer: + return make_response("Unknown slicer {slicer}".format(**locals()), 404) + except ProfileAlreadyExists: + return make_response("A profile named {name} already exists for slicer {slicer}".format(**locals()), 409) result = _getSlicingProfileData(slicer, name, profile) r = make_response(jsonify(result), 201) @@ -99,15 +103,15 @@ def slicingAddSlicerProfile(slicer, name): @api.route("/slicing//profiles/", methods=["PATCH"]) @restricted_access def slicingPatchSlicerProfile(slicer, name): - if not slicer in slicingManager.registered_slicers: - return make_response("Unknown slicer {slicer}".format(**locals()), 404) - if not "application/json" in request.headers["Content-Type"]: return make_response("Expected content-type JSON", 400) - profile = slicingManager.load_profile(slicer, name) - if not profile: - return make_response("Profile not found", 404) + try: + profile = slicingManager.load_profile(slicer, name) + except UnknownSlicer: + return make_response("Unknown slicer {slicer}".format(**locals()), 404) + except UnknownProfile: + return make_response("Profile {name} for slicer {slicer} not found".format(**locals()), 404) try: json_data = request.json @@ -133,23 +137,26 @@ def slicingPatchSlicerProfile(slicer, name): s().set(["slicing", "defaultProfiles"], default_profiles) s().save(force=True) - slicingManager.save_profile(slicer, name, profile, overrides=data, display_name=display_name, description=description) - return NO_CONTENT + try: + saved_profile = slicingManager.save_profile(slicer, name, profile, overrides=data, display_name=display_name, description=description) + except ProfileAlreadyExists: + return make_response("Profile named {name} for slicer {slicer} does already exist".format(**locals()), 409) + return jsonify(_getSlicingProfileData(slicer, name, saved_profile)) @api.route("/slicing//profiles/", methods=["DELETE"]) @restricted_access def slicingDelSlicerProfile(slicer, name): - if not slicer in slicingManager.registered_slicers: + try: + slicingManager.delete_profile(slicer, name) + except UnknownSlicer: return make_response("Unknown slicer {slicer}".format(**locals()), 404) + except UnknownProfile: + return make_response("Unknown profile {name} for slicer {slicer}".format(**locals()), 404) - slicingManager.delete_profile(slicer, name) return NO_CONTENT def _getSlicingProfilesData(slicer, require_configured=False): - try: - profiles = slicingManager.all_profiles(slicer, require_configured=require_configured) - except SlicerNotConfigured: - return dict() + profiles = slicingManager.all_profiles(slicer, require_configured=require_configured) result = dict() for name, profile in profiles.items(): diff --git a/src/octoprint/slicing/__init__.py b/src/octoprint/slicing/__init__.py index 8d4848e4..083e5611 100644 --- a/src/octoprint/slicing/__init__.py +++ b/src/octoprint/slicing/__init__.py @@ -1,4 +1,17 @@ # coding=utf-8 +""" +In this module the slicing support of OctoPrint is encapsulated. + +.. autoclass:: SlicingProfile + :members: + +.. autoclass:: TemporaryProfile + :members: + +.. autoclass:: SlicingManager + :members: +""" + from __future__ import absolute_import __author__ = "Gina Häußge " @@ -15,6 +28,17 @@ from .exceptions import * class SlicingProfile(object): + """ + A wrapper for slicing profiles, both meta data and actual profile data. + + Arguments: + slicer (str): Identifier of the slicer this profile belongs to. + name (str): Identifier of this slicing profile. + data (object): Profile data, actual structure depends on individual slicer implementation. + display_name (str): Displayable name for this slicing profile. + description (str): Description of this slicing profile. + """ + def __init__(self, slicer, name, data, display_name=None, description=None): self.slicer = slicer self.name = name @@ -24,6 +48,27 @@ class SlicingProfile(object): class TemporaryProfile(object): + """ + A wrapper for a temporary slicing profile to be used for a slicing job, based on a :class:`SlicingProfile` with + optional ``overrides`` applied through the supplied ``save_profile`` method. + + Usage example: + + .. code-block:: python + + temporary = TemporaryProfile(my_slicer.save_slicer_profile, my_default_profile, + overrides=my_overrides) + with (temporary) as profile_path: + my_slicer.do_slice(..., profile_path=profile_path, ...) + + Arguments: + save_profile (callable): Method to use for saving the temporary profile, also responsible for applying the + supplied ``overrides``. This will be called according to the method signature of + :meth:`~octoprint.plugin.SlicerPlugin.save_slicer_profile`. + profile (SlicingProfile): The profile from which to derive the temporary profile. + overrides (dict): Optional overrides to apply to the ``profile`` for creation of the temporary profile. + """ + def __init__(self, save_profile, profile, overrides=None): self.save_profile = save_profile self.profile = profile @@ -47,6 +92,15 @@ class TemporaryProfile(object): class SlicingManager(object): + """ + The :class:`SlicingManager` is responsible for managing available slicers and slicing profiles. + + Arguments: + profile_path (str): Absolute path to the base folder where all slicing profiles are stored. + printer_profile_manager (~octoprint.printer.profile.PrinterProfileManager): :class:`~octoprint.printer.profile.PrinterProfileManager` + instance to use for accessing available printer profiles, most importantly the currently selected one. + """ + def __init__(self, profile_path, printer_profile_manager): self._profile_path = profile_path self._printer_profile_manager = printer_profile_manager @@ -54,37 +108,55 @@ class SlicingManager(object): self._slicers = dict() self._slicer_names = dict() - self._progress_callbacks = [] - self._last_progress_report = None - def initialize(self): + """ + Initializes the slicing manager by loading and initializing all available + :class:`~octoprint.plugin.SlicerPlugin` implementations. + """ self._load_slicers() - def register_progress_callback(self, callback): - self._progress_callbacks.append(callback) - - def unregister_progress_callback(self, callback): - self._progress_callbacks.remove(callback) - def _load_slicers(self): + """ + Retrieves all registered :class:`~octoprint.plugin.SlicerPlugin` implementations and registers them as + available slicers. + """ plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SlicerPlugin) for name, plugin in plugins.items(): self._slicers[plugin.get_slicer_properties()["type"]] = plugin @property def slicing_enabled(self): + """ + Returns: + boolean: True if there is at least one configured slicer available, False otherwise. + """ return len(self.configured_slicers) > 0 @property def registered_slicers(self): + """ + Returns: + list of str: Identifiers of all available slicers. + """ return self._slicers.keys() @property def configured_slicers(self): + """ + Returns: + list of str: Identifiers of all available configured slicers. + """ return map(lambda slicer: slicer.get_slicer_properties()["type"], filter(lambda slicer: slicer.is_slicer_configured(), self._slicers.values())) @property def default_slicer(self): + """ + Retrieves the default slicer. + + Returns: + str: The identifier of the default slicer or ``None`` if the default slicer is not registered in the + system. + """ slicer_name = settings().get(["slicing", "defaultSlicer"]) if slicer_name in self.registered_slicers: return slicer_name @@ -92,11 +164,90 @@ class SlicingManager(object): return None def get_slicer(self, slicer, require_configured=True): - if slicer in self._slicers and (not require_configured or self._slicers[slicer].is_slicer_configured()): - return self._slicers[slicer] - raise SlicerNotConfigured(slicer) + """ + Retrieves the slicer named ``slicer``. If ``require_configured`` is set to True (the default) an exception + will be raised if the slicer is not yet configured. + + Arguments: + slicer (str): Identifier of the slicer to return + require_configured (boolean): Whether to raise an exception if the slicer has not been configured yet (True, + the default), or also return an unconfigured slicer (False). + + Raises: + ~octoprint.slicing.exceptions.UnknownSlicer: The ``slicer`` is unknown. + ~octoprint.slicing.exceptions.SlicerNotConfigured: The ``slicer`` is not yet configured and ``require_configured`` was set to True. + """ + + if not slicer in self._slicers: + raise UnknownSlicer(slicer) + + if require_configured and not self._slicers[slicer].is_slicer_configured(): + raise SlicerNotConfigured(slicer) + + return self._slicers[slicer] + + def slice(self, slicer_name, source_path, dest_path, profile_name, callback, + callback_args=None, callback_kwargs=None, overrides=None, + on_progress=None, on_progress_args=None, on_progress_kwargs=None, printer_profile_id=None, position=None): + """ + Slices ``source_path`` to ``dest_path`` using slicer ``slicer_name`` and slicing profile ``profile_name``. + Since slicing happens asynchronously, ``callback`` will be called when slicing has finished (either successfully + or not), with ``callback_args`` and ``callback_kwargs`` supplied. + + If ``callback_args`` is left out, an empty argument list will be assumed for the callback. If ``callback_kwargs`` + is left out, likewise an empty keyword argument list will be assumed for the callback. Note that in any case + the callback *must* support being called with the following optional keyword arguments: + + _analysis + If the slicer returned analysis data of the created machine code as part of its slicing result, this keyword + argument will contain that data. + _error + If there was an error while slicing this keyword argument will contain the error message as returned from + the slicer. + _cancelled + If the slicing job was cancelled this keyword argument will be set to True. + + Additionally callees may specify ``overrides`` for the specified slicing profile, e.g. a different extrusion + temperature than defined in the profile or a different layer height. + + With ``on_progress``, ``on_progress_args`` and ``on_progress_kwargs``, callees may specify a callback plus + arguments and keyword arguments to call upon progress reports from the slicing job. The progress callback will + be called with a keyword argument ``_progress`` containing the current slicing progress as a value between 0 + and 1 plus all additionally specified args and kwargs. + + If a different printer profile than the currently selected one is to be used for slicing, its id can be provided + via the keyword argument ``printer_profile_id``. + + If the ``source_path`` is to be a sliced at a different position than the print bed center, this ``position`` can + be supplied as a dictionary defining the ``x`` and ``y`` coordinate in print bed coordinates of the model's center. + + Arguments: + slicer_name (str): The identifier of the slicer to use for slicing. + source_path (str): The absolute path to the source file to slice. + dest_path (str): The absolute path to the destination file to slice to. + profile_name (str): The name of the slicing profile to use. + callback (callable): A callback to call after slicing has finished. + callback_args (list or tuple): Arguments of the callback to call after slicing has finished. Defaults to + an empty list. + callback_kwargs (dict): Keyword arguments for the callback to call after slicing has finished, will be + extended by ``_analysis``, ``_error`` or ``_cancelled`` as described above! Defaults to an empty + dictionary. + overrides (dict): Overrides for the printer profile to apply. + on_progress (callable): Callback to call upon slicing progress. + on_progress_args (list or tuple): Arguments of the progress callback. Defaults to an empty list. + on_progress_kwargs (dict): Keyword arguments of the progress callback, will be extended by ``_progress`` + as described above! Defaults to an empty dictionary. + printer_profile_id (str): Identifier of the printer profile for which to slice, if another than the + one currently selected is to be used. + position (dict): Dictionary containing the ``x`` and ``y`` coordinate in the print bed's coordinate system + of the sliced model's center. If not provided the model will be positioned at the print bed's center. + Example: ``dict(x=10,y=20)``. + + Raises: + ~octoprint.slicing.exceptions.UnknownSlicer: The slicer specified via ``slicer_name`` is unknown. + ~octoprint.slicing.exceptions.SlicerNotConfigured: The slice specified via ``slicer_name`` is not configured yet. + """ - def slice(self, slicer_name, source_path, dest_path, profile_name, callback, callback_args=None, callback_kwargs=None, overrides=None, on_progress=None, on_progress_args=None, on_progress_kwargs=None, printer_profile_id=None, position=None): if callback_args is None: callback_args = () if callback_kwargs is None: @@ -125,7 +276,7 @@ class SlicingManager(object): def slicer_worker(slicer, model_path, machinecode_path, profile_name, overrides, printer_profile, position, callback, callback_args, callback_kwargs): try: slicer_name = slicer.get_slicer_properties()["type"] - with self.temporary_profile(slicer_name, name=profile_name, overrides=overrides) as profile_path: + with self._temporary_profile(slicer_name, name=profile_name, overrides=overrides) as profile_path: ok, result = slicer.do_slice( model_path, printer_profile, @@ -151,16 +302,41 @@ class SlicingManager(object): args=(slicer, source_path, dest_path, profile_name, overrides, printer_profile, position, callback, callback_args, callback_kwargs)) slicer_worker_thread.daemon = True slicer_worker_thread.start() - return True, None def cancel_slicing(self, slicer_name, source_path, dest_path): - if not slicer_name in self.registered_slicers: - raise UnknownSlicer(slicer_name) + """ + Cancels the slicing job on slicer ``slicer_name`` from ``source_path`` to ``dest_path``. + + Arguments: + slicer_name (str): Identifier of the slicer on which to cancel the job. + source_path (str): The absolute path to the source file being sliced. + dest_path (str): The absolute path to the destination file being sliced to. + + Raises: + ~octoprint.slicing.exceptions.UnknownSlicer: The slicer specified via ``slicer_name`` is unknown. + """ slicer = self.get_slicer(slicer_name) slicer.cancel_slicing(dest_path) def load_profile(self, slicer, name, require_configured=True): + """ + Loads the slicing profile for ``slicer`` with the given profile ``name`` and returns it. If it can't be loaded + due to an :class:`IOError` ``None`` will be returned instead. + + If ``require_configured`` is True (the default) a :class:`SlicerNotConfigured` exception will be raised + if the indicated ``slicer`` has not yet been configured. + + Returns: + SlicingProfile: The requested slicing profile or None if it could not be loaded. + + Raises: + ~octoprint.slicing.exceptions.UnknownSlicer: The slicer specified via ``slicer`` is unknown. + ~octoprint.slicing.exceptions.SlicerNotConfigured: The slicer specified via ``slicer`` has not yet been configured and + ``require_configured`` was True. + ~octoprint.slicing.exceptions.UnknownProfile: The profile for slicer ``slicer`` named ``name`` does not exist. + """ + if not slicer in self.registered_slicers: raise UnknownSlicer(slicer) @@ -171,6 +347,38 @@ class SlicingManager(object): return self._load_profile_from_path(slicer, path, require_configured=require_configured) def save_profile(self, slicer, name, profile, overrides=None, allow_overwrite=True, display_name=None, description=None): + """ + Saves the slicer profile ``profile`` for slicer ``slicer`` under name ``name``. + + ``profile`` may be either a :class:`SlicingProfile` or a :class:`dict`. + + If it's a :class:`SlicingProfile`, its :attr:`~SlicingProfile.slicer``, :attr:`~SlicingProfile.name` and - if + provided - :attr:`~SlicingProfile.display_name` and :attr:`~SlicingProfile.description` attributes will be + overwritten with the supplied values. + + If it's a :class:`dict`, a new :class:`SlicingProfile` instance will be created with the supplied meta data and + the profile data as the :attr:`~SlicingProfile.data` attribute. + + Arguments: + slicer (str): Identifier of the slicer for which to save the ``profile``. + name (str): Identifier under which to save the ``profile``. + profile (SlicingProfile or dict): The :class:`SlicingProfile` or a :class:`dict` containing the profile + data of the profile the save. + overrides (dict): Overrides to apply to the ``profile`` before saving it. + allow_overwrite (boolean): If True (default) if a profile for the same ``slicer`` of the same ``name`` + already exists, it will be overwritten. Otherwise an exception will be thrown. + display_name (str): The name to display to the user for the profile. + description (str): A description of the profile. + + Returns: + SlicingProfile: The saved profile (including the applied overrides). + + Raises: + ValueError: The supplied ``profile`` is neither a :class:`SlicingProfile` nor a :class:`dict`. + ~octoprint.slicing.exceptions.UnknownSlicer: The slicer ``slicer`` is unknown. + ~octoprint.slicing.exceptions.ProfileAlreadyExists: A profile with name ``name`` already exists for ``slicer`` and ``allow_overwrite`` is + False. + """ if not slicer in self.registered_slicers: raise UnknownSlicer(slicer) @@ -178,7 +386,7 @@ class SlicingManager(object): if isinstance(profile, dict): profile = SlicingProfile(slicer, name, profile, display_name=display_name, description=description) else: - raise ValueError("profile must be a SlicingProfile") + raise ValueError("profile must be a SlicingProfile or a dict") else: profile.slicer = slicer profile.name = name @@ -191,7 +399,7 @@ class SlicingManager(object): self._save_profile_to_path(slicer, path, profile, overrides=overrides, allow_overwrite=allow_overwrite) return profile - def temporary_profile(self, slicer, name=None, overrides=None): + def _temporary_profile(self, slicer, name=None, overrides=None): if not slicer in self.registered_slicers: raise UnknownSlicer(slicer) @@ -199,27 +407,59 @@ class SlicingManager(object): if name: try: profile = self.load_profile(slicer, name) - except IOError: - profile = self._get_default_profile(slicer) - else: - if profile is None: - profile = self._get_default_profile(slicer) + except (UnknownProfile, IOError): + # in that case we'll use the default profile + pass return TemporaryProfile(self.get_slicer(slicer).save_slicer_profile, profile, overrides=overrides) def delete_profile(self, slicer, name): + """ + Deletes the profile ``name`` for the specified ``slicer``. + + If the profile does not exist, nothing will happen. + + Arguments: + slicer (str): Identifier of the slicer for which to delete the profile. + name (str): Identifier of the profile to delete. + + Raises: + ~octoprint.slicing.exceptions.UnknownSlicer: The slicer ``slicer`` is unknown. + """ + if not slicer in self.registered_slicers: raise UnknownSlicer(slicer) if not name: raise ValueError("name must be set") - path = self.get_profile_path(slicer, name) - if not os.path.exists(path) or not os.path.isfile(path): + try: + path = self.get_profile_path(slicer, name) + except UnknownProfile: return os.remove(path) def all_profiles(self, slicer, require_configured=False): + """ + Retrieves all profiles for slicer ``slicer``. + + If ``require_configured`` is set to True (default is False), only will return the profiles if the ``slicer`` + is already configured, otherwise a :class:`SlicerNotConfigured` exception will be raised. + + Arguments: + slicer (str): Identifier of the slicer for which to retrieve all slicer profiles + require_configured (boolean): Whether to require the slicer ``slicer`` to be already configured (True) + or not (False, default). If False and the slicer is not yet configured, a :class:`~octoprint.slicing.exceptions.SlicerNotConfigured` + exception will be raised. + + Returns: + list of SlicingProfile: A list of all :class:`SlicingProfile` instances available for the slicer ``slicer``. + + Raises: + ~octoprint.slicing.exceptions.UnknownSlicer: The slicer ``slicer`` is unknown. + ~octoprint.slicing.exceptions.SlicerNotConfigured: The slicer ``slicer`` is not configured and ``require_configured`` was True. + """ + if not slicer in self.registered_slicers: raise UnknownSlicer(slicer) if require_configured and not slicer in self.configured_slicers: @@ -239,6 +479,19 @@ class SlicingManager(object): return profiles def get_slicer_profile_path(self, slicer): + """ + Retrieves the path where the profiles for slicer ``slicer`` are stored. + + Arguments: + slicer (str): Identifier of the slicer for which to retrieve the path. + + Returns: + str: The absolute path to the folder where the slicer's profiles are stored. + + Raises: + ~octoprint.slicing.exceptions.UnknownSlicer: The slicer ``slicer`` is unknown. + """ + if not slicer in self.registered_slicers: raise UnknownSlicer(slicer) @@ -248,6 +501,25 @@ class SlicingManager(object): return path def get_profile_path(self, slicer, name, must_exist=False): + """ + Retrieves the path to the profile named ``name`` for slicer ``slicer``. + + If ``must_exist`` is set to True (defaults to False) a :class:`UnknownProfile` exception will be raised if the + profile doesn't exist yet. + + Arguments: + slicer (str): Identifier of the slicer to which the profile belongs to. + name (str): Identifier of the profile for which to retrieve the path. + must_exist (boolean): Whether the path must exist (True) or not (False, default). + + Returns: + str: The absolute path to the profile identified by ``name`` for slicer ``slicer``. + + Raises: + ~octoprint.slicing.exceptions.UnknownSlicer: The slicer ``slicer`` is unknown. + ~octoprint.slicing.exceptions.UnknownProfile: The profile named ``name`` doesn't exist and ``must_exist`` was True. + """ + if not slicer in self.registered_slicers: raise UnknownSlicer(slicer) @@ -260,7 +532,7 @@ class SlicingManager(object): if not os.path.realpath(path).startswith(self._profile_path): raise IOError("Path to profile {name} tried to break out of allows sub path".format(**locals())) if must_exist and not (os.path.exists(path) and os.path.isfile(path)): - raise IOError("Profile {name} doesn't exist".format(**locals())) + raise UnknownProfile(slicer, name) return path def _sanitize(self, name): @@ -287,7 +559,8 @@ class SlicingManager(object): if default_profiles and slicer in default_profiles: try: return self.load_profile(slicer, default_profiles[slicer]) - except: + except (UnknownProfile, IOError): + # in that case we'll use the slicers predefined default profile pass return self.get_slicer(slicer).get_slicer_default_profile() diff --git a/src/octoprint/slicing/exceptions.py b/src/octoprint/slicing/exceptions.py index 4eefe4ae..c726f508 100644 --- a/src/octoprint/slicing/exceptions.py +++ b/src/octoprint/slicing/exceptions.py @@ -1,4 +1,31 @@ # coding=utf-8 +""" +Slicing related exceptions. + +.. autoclass:: SlicingException + +.. autoclass:: SlicingCancelled + :show-inheritance: + +.. autoclass:: SlicerException + :show-inheritance: + +.. autoclass:: UnknownSlicer + :show-inheritance: + +.. autoclass:: SlicerNotConfigured + :show-inheritance: + +.. autoclass:: ProfileException + +.. autoclass:: UnknownProfile + :show-inheritance: + +.. autoclass:: ProfileAlreadyExists + :show-inheritance: + +""" + from __future__ import absolute_import __author__ = "Gina Häußge " @@ -7,22 +34,74 @@ __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms class SlicingException(BaseException): + """ + Base exception of all slicing related exceptions. + """ pass class SlicingCancelled(SlicingException): + """ + Raised if a slicing job was cancelled. + """ pass class SlicerException(SlicingException): + """ + Base exception of all slicer related exceptions. + + .. attribute:: slicer + + Identifier of the slicer for which the exception was raised. + """ def __init__(self, slicer, *args, **kwargs): super(SlicingException, self).__init__(*args, **kwargs) self.slicer = slicer class SlicerNotConfigured(SlicerException): + """ + Raised if a slicer is not yet configured but must be configured to proceed. + """ def __init__(self, slicer, *args, **kwargs): super(SlicerException, self).__init__(slicer, *args, **kwargs) self.message = "Slicer not configured: {slicer}".format(slicer=slicer) class UnknownSlicer(SlicerException): + """ + Raised if a slicer is unknown. + """ def __init__(self, slicer, *args, **kwargs): super(SlicerException, self).__init__(slicer, *args, **kwargs) self.message = "No such slicer: {slicer}".format(slicer=slicer) + +class ProfileException(BaseException): + """ + Base exception of all slicing profile related exceptions. + + .. attribute:: slicer + + Identifier of the slicer to which the profile belongs. + + .. attribute:: profile + + Identifier of the profile for which the exception was raised. + """ + def __init__(self, slicer, profile, *args, **kwargs): + super(BaseException, self).__init__(*args, **kwargs) + self.slicer = slicer + self.profile = profile + +class UnknownProfile(ProfileException): + """ + Raised if a slicing profile does not exist but must exist to proceed. + """ + def __init__(self, slicer, profile, *args, **kwargs): + super(ProfileException, self).__init__(slicer, profile, *args, **kwargs) + self.message = "Profile {profile} for slicer {slicer} does not exist".format(profile=profile, slicer=slicer) + +class ProfileAlreadyExists(ProfileException): + """ + Raised if a slicing profile already exists and must not be overwritten. + """ + def __init__(self, slicer, profile, *args, **kwargs): + super(ProfileException, self).__init__(slicer, profile, *args, **kwargs) + self.message = "Profile {profile} for slicer {slicer} already exists".format(profile=profile, slicer=slicer) \ No newline at end of file