MrDraw/src/octoprint/slicing/__init__.py
Gina Häußge bccc706329 First throw at caching of API methods
Most caching is left to the client, by utilizing ETag and Last-Modified headers.

Where it was easily achievable, an additional server side miniature cache of intermediary
results was introduced (e.g. for the files). The regular cached decorator was not used
since it targets caching full responses, and the responses in question already contained
client request specific data. Caching "one step earlier" allows better usage of the cache here.

Also introduced a dependency on the scandir module, to get a bit of a performance boost
on os.walk and os.listdir (which have been replaced with scandir.walk and scandir.listdir
respectively). See https://github.com/benhoyt/scandir#background on why that made
sense.
2016-08-30 19:02:30 +02:00

671 lines
26 KiB
Python

# 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, division, print_function
__author__ = "Gina Häußge <osd@foosel.net>"
__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html'
__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License"
import os
try:
from os import scandir
except ImportError:
from scandir import scandir
import octoprint.plugin
import octoprint.events
import octoprint.util
from octoprint.settings import settings
import logging
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.
default (bool): Whether this is the default slicing profile for the slicer.
"""
def __init__(self, slicer, name, data, display_name=None, description=None, default=False):
self.slicer = slicer
self.name = name
self.data = data
self.display_name = display_name
self.description = description
self.default = default
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
self.overrides = overrides
def __enter__(self):
import tempfile
temp_profile = tempfile.NamedTemporaryFile(prefix="slicing-profile-temp-", suffix=".profile", delete=False)
temp_profile.close()
self.temp_path = temp_profile.name
self.save_profile(self.temp_path, self.profile, overrides=self.overrides)
return self.temp_path
def __exit__(self, type, value, traceback):
import os
try:
os.remove(self.temp_path)
except:
pass
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._logger = logging.getLogger(__name__)
self._profile_path = profile_path
self._printer_profile_manager = printer_profile_manager
self._slicers = dict()
self._slicer_names = dict()
def initialize(self):
"""
Initializes the slicing manager by loading and initializing all available
:class:`~octoprint.plugin.SlicerPlugin` implementations.
"""
self.reload_slicers()
def reload_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)
slicers = dict()
for plugin in plugins:
try:
slicers[plugin.get_slicer_properties()["type"]] = plugin
except:
self._logger.exception("Error while getting properties from slicer {}, ignoring it".format(plugin._identifier))
continue
self._slicers = slicers
@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
else:
return None
def get_slicer(self, slicer, require_configured=True):
"""
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.
"""
if callback_args is None:
callback_args = ()
if callback_kwargs is None:
callback_kwargs = dict()
if not slicer_name in self.configured_slicers:
if not slicer_name in self.registered_slicers:
error = "No such slicer: {slicer_name}".format(**locals())
exc = UnknownSlicer(slicer_name)
else:
error = "Slicer not configured: {slicer_name}".format(**locals())
exc = SlicerNotConfigured(slicer_name)
callback_kwargs.update(dict(_error=error, _exc=exc))
callback(*callback_args, **callback_kwargs)
raise exc
slicer = self.get_slicer(slicer_name)
printer_profile = None
if printer_profile_id is not None:
printer_profile = self._printer_profile_manager.get(printer_profile_id)
if printer_profile is None:
printer_profile = self._printer_profile_manager.get_current_or_default()
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:
ok, result = slicer.do_slice(
model_path,
printer_profile,
machinecode_path=machinecode_path,
profile_path=profile_path,
position=position,
on_progress=on_progress,
on_progress_args=on_progress_args,
on_progress_kwargs=on_progress_kwargs
)
if not ok:
callback_kwargs.update(dict(_error=result))
elif result is not None and isinstance(result, dict) and "analysis" in result:
callback_kwargs.update(dict(_analysis=result["analysis"]))
except SlicingCancelled:
callback_kwargs.update(dict(_cancelled=True))
finally:
callback(*callback_args, **callback_kwargs)
import threading
slicer_worker_thread = threading.Thread(target=slicer_worker,
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()
def cancel_slicing(self, slicer_name, source_path, dest_path):
"""
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)
try:
path = self.get_profile_path(slicer, name, must_exist=True)
except IOError:
return None
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)
if not isinstance(profile, SlicingProfile):
if isinstance(profile, dict):
profile = SlicingProfile(slicer, name, profile, display_name=display_name, description=description)
else:
raise ValueError("profile must be a SlicingProfile or a dict")
else:
profile.slicer = slicer
profile.name = name
if display_name is not None:
profile.display_name = display_name
if description is not None:
profile.description = description
path = self.get_profile_path(slicer, name)
is_overwrite = os.path.exists(path)
if is_overwrite and not allow_overwrite:
raise ProfileAlreadyExists(slicer, profile.name)
self._save_profile_to_path(slicer, path, profile, overrides=overrides, allow_overwrite=allow_overwrite)
payload = dict(slicer=slicer,
profile=name)
event = octoprint.events.Events.SLICING_PROFILE_MODIFIED if is_overwrite else octoprint.events.Events.SLICING_PROFILE_ADDED
octoprint.events.eventManager().fire(event, payload)
return profile
def _temporary_profile(self, slicer, name=None, overrides=None):
if not slicer in self.registered_slicers:
raise UnknownSlicer(slicer)
profile = self._get_default_profile(slicer)
if name:
try:
profile = self.load_profile(slicer, name)
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.
~octoprint.slicing.exceptions.CouldNotDeleteProfile: There was an error while deleting the profile.
"""
if not slicer in self.registered_slicers:
raise UnknownSlicer(slicer)
if not name:
raise ValueError("name must be set")
try:
try:
path = self.get_profile_path(slicer, name, must_exist=True)
except UnknownProfile:
return
os.remove(path)
except ProfileException as e:
raise e
except Exception as e:
raise CouldNotDeleteProfile(slicer, name, cause=e)
else:
octoprint.events.eventManager().fire(octoprint.events.Events.SLICING_PROFILE_DELETED, dict(slicer=slicer, profile=name))
def set_default_profile(self, slicer, name, require_configured=False,
require_exists=True):
"""
Sets the given profile as default profile for the slicer.
Arguments:
slicer (str): Identifier of the slicer for which to set the default
profile.
name (str): Identifier of the profile to set as default.
require_configured (bool): Whether the slicer needs to be configured
for the action to succeed. Defaults to false. Will raise a
SlicerNotConfigured error if true and the slicer has not been
configured yet.
require_exists (bool): Whether the profile is required to exist in
order to be set as default. Defaults to true. Will raise a
UnknownProfile error if true and the profile is unknown.
Raises:
~octoprint.slicing.exceptions.UnknownSlicer: The slicer ``slicer``
is unknown
~octoprint.slicing.exceptions.SlicerNotConfigured: The slicer ``slicer``
has not yet been configured and ``require_configured`` was true.
~octoprint.slicing.exceptions.UnknownProfile: The profile ``name``
was unknown for slicer ``slicer`` and ``require_exists`` was
true.
"""
if not slicer in self.registered_slicers:
raise UnknownSlicer(slicer)
if require_configured and not slicer in self.configured_slicers:
raise SlicerNotConfigured(slicer)
if not name:
raise ValueError("name must be set")
if require_exists and not name in self.all_profiles(slicer, require_configured=require_configured):
raise UnknownProfile(slicer, name)
default_profiles = settings().get(["slicing", "defaultProfiles"])
if not default_profiles:
default_profiles = dict()
default_profiles[slicer] = name
settings().set(["slicing", "defaultProfiles"], default_profiles)
settings().save(force=True)
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:
dict of SlicingProfile: A dict of all :class:`SlicingProfile` instances available for the slicer ``slicer``, mapped by the identifier.
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:
raise SlicerNotConfigured(slicer)
profiles = dict()
slicer_profile_path = self.get_slicer_profile_path(slicer)
for entry in scandir(slicer_profile_path):
if not entry.name.endswith(".profile") or octoprint.util.is_hidden_path(entry.name):
# we are only interested in profiles and no hidden files
continue
profile_name = entry.name[:-len(".profile")]
profiles[profile_name] = self._load_profile_from_path(slicer, entry.path, require_configured=require_configured)
return profiles
def profiles_last_modified(self, slicer):
"""
Retrieves the last modification date of ``slicer``'s profiles.
Args:
slicer (str): the slicer for which to retrieve the last modification date
Returns:
(float) the time stamp of the last modification of the slicer's profiles
"""
if not slicer in self.registered_slicers:
raise UnknownSlicer(slicer)
slicer_profile_path = self.get_slicer_profile_path(slicer)
lms = [os.stat(slicer_profile_path).st_mtime]
lms += [os.stat(entry.path).st_mtime for entry in scandir(slicer_profile_path) if entry.name.endswith(".profile")]
return max(lms)
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)
path = os.path.join(self._profile_path, slicer)
if not os.path.exists(path):
os.makedirs(path)
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)
if not name:
raise ValueError("name must be set")
name = self._sanitize(name)
path = os.path.join(self.get_slicer_profile_path(slicer), "{name}.profile".format(name=name))
if not os.path.realpath(path).startswith(os.path.realpath(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 UnknownProfile(slicer, name)
return path
def _sanitize(self, name):
if name is None:
return None
if "/" in name or "\\" in name:
raise ValueError("name must not contain / or \\")
import string
valid_chars = "-_.() {ascii}{digits}".format(ascii=string.ascii_letters, digits=string.digits)
sanitized_name = ''.join(c for c in name if c in valid_chars)
sanitized_name = sanitized_name.replace(" ", "_")
return sanitized_name
def _load_profile_from_path(self, slicer, path, require_configured=False):
profile = self.get_slicer(slicer, require_configured=require_configured).get_slicer_profile(path)
default_profiles = settings().get(["slicing", "defaultProfiles"])
if default_profiles and slicer in default_profiles:
profile.default = default_profiles[slicer] == profile.name
return profile
def _save_profile_to_path(self, slicer, path, profile, allow_overwrite=True, overrides=None, require_configured=False):
self.get_slicer(slicer, require_configured=require_configured).save_slicer_profile(path, profile, allow_overwrite=allow_overwrite, overrides=overrides)
def _get_default_profile(self, slicer):
default_profiles = settings().get(["slicing", "defaultProfiles"])
if default_profiles and slicer in default_profiles:
try:
return self.load_profile(slicer, default_profiles[slicer])
except (UnknownProfile, IOError):
# in that case we'll use the slicers predefined default profile
pass
return self.get_slicer(slicer).get_slicer_default_profile()