pax_global_header00006660000000000000000000000064141334732630014520gustar00rootroot0000000000000052 comment=8e255aaad10b09c9cfc6e795f33968fd642e26c9 libCharon-4.13.0/000077500000000000000000000000001413347326300135065ustar00rootroot00000000000000libCharon-4.13.0/.dockerignore000066400000000000000000000003301413347326300161560ustar00rootroot00000000000000# Items that don't need to be in a Docker image. # Anything not used by the build system should go here. Dockerfile Dockerfile.local Jenkinsfile .dockerignore .gitignore .gitmodules .git .coverage .build.sh README.mdlibCharon-4.13.0/.gitignore000066400000000000000000000024231413347326300154770ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class .pytest_cache # Build folder _build_armhf/* # VIM temp files *.swp # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg *.deb # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ cov_report/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env .idea # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # KDevelop project files *.kdev4 libCharon-4.13.0/.gitlab-ci.yml000066400000000000000000000014371413347326300161470ustar00rootroot00000000000000include: - project: ultimaker/embedded/prime-jedi ref: master file: /gitlab_ci_templates/jedi-gitlab-ci-template.yml complexity: extends: - .jobs_common - .build_test_common stage: test script: - ./ci/complexity_analysis.sh dead_code: extends: - .jobs_common - .build_test_common stage: test script: - ./ci/dead_code_analysis.sh style: extends: - .jobs_common - .build_test_common stage: test script: - git fetch origin master:master - ./ci/style_analysis.sh mypy: extends: - .jobs_common - .build_test_common stage: test script: - git fetch origin master:master - ./ci/mypy.sh pytest: extends: - .jobs_common - .build_test_common stage: test script: - ./ci/pytest.sh libCharon-4.13.0/CMakeLists.txt000066400000000000000000000044511413347326300162520ustar00rootroot00000000000000project(charon NONE) cmake_minimum_required(VERSION 3.6) #Tested only with 3.6.1 and 3.9.1. # FIXME: Remove the code for CMake <3.12 once we have switched over completely. # FindPython3 is a new module since CMake 3.12. It deprecates FindPythonInterp and FindPythonLibs. if(${CMAKE_VERSION} VERSION_LESS 3.12) # Use FindPythonInterp and FindPythonLibs for CMake <3.12 find_package(PythonInterp 3.4 REQUIRED) set(Python3_EXECUTABLE ${PYTHON_EXECUTABLE}) set(Python3_VERSION_MAJOR ${PYTHON_VERSION_MAJOR}) set(Python3_VERSION_MINOR ${PYTHON_VERSION_MINOR}) else() # Use FindPython3 for CMake >=3.12 find_package(Python3 ${CURA_PYTHON_VERSION} EXACT REQUIRED COMPONENTS Interpreter) endif() option(INSTALL_SERVICE "Install the Charon DBus-service" ON) option(INSTALL_CLIENT "Install the Charon Client library" ON) if(EXISTS /etc/debian_version) set(CHARON_INSTALL_PATH lib${LIB_SUFFIX}/python${Python3_VERSION_MAJOR}/dist-packages) else() set(CHARON_INSTALL_PATH lib${LIB_SUFFIX}/python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/site-packages) endif() set(_excludes PATTERN __pycache__ EXCLUDE) if(NOT INSTALL_SERVICE) set(_excludes ${_excludes} PATTERN "Service" EXCLUDE) endif() if(NOT INSTALL_CLIENT) set(_excludes ${_excludes} PATTERN "Client" EXCLUDE) endif() install(DIRECTORY Charon DESTINATION ${CHARON_INSTALL_PATH} ${_excludes}) if(INSTALL_SERVICE) install(FILES service/charon.service DESTINATION lib/systemd/system) install(FILES service/nl.ultimaker.charon.conf DESTINATION share/dbus-1/system.d) endif() include(CPackConfig.cmake) ####################Loading the unit tests.################### enable_testing() include(CMakeParseArguments) if(NOT _PYTHONPATH) set(_PYTHONPATH ${CMAKE_SOURCE_DIR}) endif() if(WIN32) string(REPLACE "|" "\\;" _PYTHONPATH ${_PYTHONPATH}) set(_PYTHONPATH "${_PYTHONPATH}\\;$ENV{PYTHONPATH}") else() string(REPLACE "|" ":" _PYTHONPATH ${_PYTHONPATH}) set(_PYTHONPATH "${_PYTHONPATH}:$ENV{PYTHONPATH}") endif() add_test( NAME pytest-main COMMAND ${Python3_EXECUTABLE} -m pytest --junitxml=${CMAKE_BINARY_DIR}/junit-pytest-main.xml ${CMAKE_SOURCE_DIR}/tests ) set_tests_properties(pytest-main PROPERTIES ENVIRONMENT LANG=C) set_tests_properties(pytest-main PROPERTIES ENVIRONMENT "PYTHONPATH=${_PYTHONPATH}") libCharon-4.13.0/CPackConfig.cmake000077500000000000000000000013131413347326300166200ustar00rootroot00000000000000set(CPACK_PACKAGE_VENDOR "Ultimaker") set(CPACK_PACKAGE_CONTACT "Ultimaker ") set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Library to read and write 3D printer related files including G-Code and Ultimaker File Package.") set(CPACK_PACKAGE_VERSION_MAJOR 1) set(CPACK_PACKAGE_VERSION_MINOR 0) set(CPACK_PACKAGE_VERSION_PATCH 0) set(CPACK_GENERATOR "DEB") set(CPACK_DEBIAN_PACKAGE_ARCHITECTURE all) set(DEB_DEPENDS "python3 (>= 3.4.2)" "python3-dbus (>= 1.2.0)" "dbus (>= 1.8.0)" ) string(REPLACE ";" "," DEB_DEPENDS "${DEB_DEPENDS}") set(CPACK_DEBIAN_PACKAGE_DEPENDS ${DEB_DEPENDS}) set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA "${CMAKE_CURRENT_SOURCE_DIR}/service/postinst") include(CPack) libCharon-4.13.0/Charon/000077500000000000000000000000001413347326300147205ustar00rootroot00000000000000libCharon-4.13.0/Charon/Client/000077500000000000000000000000001413347326300161365ustar00rootroot00000000000000libCharon-4.13.0/Charon/Client/DBusInterface.py000066400000000000000000000274421413347326300211770ustar00rootroot00000000000000import os import logging from typing import Callable, Optional, Union, Any # We want to use either dbus-python or QtDBus for handling DBus. # We first need to try importing the module, if that fails we know # there is no chance of using Qt in the first place. If it succeeds, # we still may not be using Qt for the main loop, but this check is # done at runtime. _has_qt = False try: from PyQt5.QtCore import QCoreApplication, QObject, pyqtSlot from PyQt5.QtDBus import QDBusConnection, QDBusMessage, QDBusReply, QDBusInterface, QDBusPendingCallWatcher _has_qt = True except ImportError: pass # Always also try to import dbus-python, since we need to determine things # at runtime. try: import dbus import dbus.mainloop.glib from gi.repository import GLib except ImportError: if not _has_qt: raise ImportError("Either QtDBus or dbus-python should be available!") GLib.threads_init() dbus.mainloop.glib.threads_init() log = logging.getLogger(__name__) ## Provides a wrapper around dbus-python or QtDBus to make DBus calls # # Since signals and async method calls are pretty tightly linked to the main # loop implementation, we try to use the DBus implementation that matches with # the main loop. This class abstracts those details away. # # There are two levels of checks, the first is an import check listed above. The # second is a runtime check to see if there is a Qt main loop. If both of those # pass, we use QtDBus. If it fails, we use dbus-python. class DBusInterface: # Define default paths that can be used. DefaultServicePath = "nl.ultimaker.charon" DefaultObjectPath = "/nl/ultimaker/charon" DefaultInterface = "nl.ultimaker.charon" ## Make a synchronous call to a DBus method. # # \param method_name The name of the method to call. # \param signature The method's argument signature. # \param args Arguments to pass to the DBus method. # # The following can only be used as keyword arguments. They default to the # Default* constants defined in this class. # # \param service_path The path to the service to call the method on. # \param object_path The object path of the service to call the method on. # \param interface The interface name of the method to call. @classmethod def callMethod(cls, method_name: str, signature: str, *args, service_path: str = DefaultServicePath, object_path: str = DefaultObjectPath, interface: str = DefaultInterface) -> Any: cls.__ensureDBusSetup() assert cls.__connection is not None if cls.__use_qt: message = QDBusMessage.createMethodCall(service_path, object_path, interface, method_name) message.setArguments(args) result = QDBusReply(cls.__connection.call(message)) if result.isValid(): return result.value() else: log.warning("Did not receive a valid reply for method call %s", method_name) log.warning(result.error().message()) return None else: return cls.__connection.call_blocking(service_path, object_path, interface, method_name, signature, args) ## Make an asynchronous call to a DBus method. # # \param method_name The name of the method to call. # \param success_callback The Callable to call if the method call was successful. # \param error_callback The Callable to call if the method call was unsuccessful. # \param signature The method's argument signature. # \param args Arguments to pass to the DBus method. # # The following can only be used as keyword arguments. They default to the # Default* constants defined in this class. # # \param service_path The path to the service to call the method on. # \param object_path The object path of the service to call the method on. # \param interface The interface name of the method to call. @classmethod def callAsync(cls, method_name: str, success_callback: Callable[..., None], error_callback: Callable[..., None], signature: str, *args, service_path: str = DefaultServicePath, object_path: str = DefaultObjectPath, interface: str = DefaultInterface) -> None: cls.__ensureDBusSetup() assert cls.__connection is not None if cls.__use_qt: assert cls.__signal_forwarder is not None message = QDBusMessage.createMethodCall(service_path, object_path, interface, method_name) message.setArguments(args) cls.__signal_forwarder.asyncCall(message, success_callback, error_callback) else: cls.__connection.call_async(service_path, object_path, interface, method_name, signature, args, success_callback, error_callback) ## Connect to a DBus signal. # # \param signal_name The name of the signal to connect to. # \param callback The callable to call when the signal is received. # # The following can only be used as keyword arguments. They default to the # Default* constants defined in this class. # # \param service_path The path to the service to call the method on. # \param object_path The object path of the service to call the method on. # \param interface The interface name of the method to call. @classmethod def connectSignal(cls, signal_name: str, callback: Callable[..., None], *, service_path: str = DefaultServicePath, object_path: str = DefaultObjectPath, interface: str = DefaultInterface) -> bool: cls.__ensureDBusSetup() if cls.__use_qt: assert cls.__signal_forwarder is not None return cls.__signal_forwarder.addConnection(service_path, object_path, interface, signal_name, callback) else: assert cls.__connection is not None cls.__connection.add_signal_receiver(callback, signal_name, interface, service_path, object_path) return True ## Disconnect from a DBus signal connection. # # \param signal_name The name of the signal to disconnect from. # \param callback The Callable to disconnect from the signal. # # The following can only be used as keyword arguments. They default to the # Default* constants defined in this class. # # \param service_path The path to the service to call the method on. # \param object_path The object path of the service to call the method on. # \param interface The interface name of the method to call. @classmethod def disconnectSignal(cls, signal_name: str, callback: Callable[..., None], *, service_path: str = DefaultServicePath, object_path: str = DefaultObjectPath, interface: str = DefaultInterface) -> bool: cls.__ensureDBusSetup() if cls.__use_qt: assert cls.__signal_forwarder is not None return cls.__signal_forwarder.removeConnection(service_path, object_path, interface, signal_name, callback) else: assert cls.__connection is not None cls.__connection.remove_signal_receiver(callback, signal_name, interface, service_path, object_path) return True # Private method to ensure we have a DBus connection. @classmethod def __ensureDBusSetup(cls): if cls.__connection: return if _has_qt and QCoreApplication.instance(): if os.environ.get("CHARON_USE_SESSION_BUS", 1) == 1: cls.__connection = QDBusConnection.sessionBus() else: cls.__connection = QDBusConnection.systemBus() cls.__signal_forwarder = DBusSignalForwarder(cls.__connection) cls.__use_qt = True return if os.environ.get("CHARON_USE_SESSION_BUS", 0) == 1: cls.__connection = dbus.Bus.get_session() else: GLib.MainLoop().run() cls.__connection = dbus.SystemBus(private=True, mainloop=dbus.mainloop.glib.DBusGMainLoop()) __use_qt = False __connection = None # type: Optional[Union[dbus.SystemBus]] __signal_forwarder = None # type: Optional[DBusSignalForwarder] if _has_qt: ## Helper class to handle QtDBus signal connections. # # QtDBus wants a QObject for its signal connections. Since we do not want # to make Request a QObject, we need to add an intermediary which receives # the signal and calls the appropriate Callable. # # In addition, to make it properly handle success/error callbacks for async # method calls, we need to create a QDBusPendingCallWatcher object that we # can listen to. This has the same limitations as QtDBus signals. class DBusSignalForwarder(QObject): def __init__(self, dbus_connection): super().__init__() self.__connection = dbus_connection self.__connection.registerObject("/" + str(id(self)), self) self.__interface_objects = {} self.__connected_signals = set() self.__callbacks = {} self.__pending_async_calls = {} ## Add a signal connection to process. def addConnection(self, service_path, object_path, interface, signal_name, callback): connection = (object_path, interface, signal_name) if connection not in self.__connected_signals: self.__connection.connect(service_path, object_path, interface, signal_name, self.handleSignal) self.__connected_signals.add(connection) if connection not in self.__callbacks: self.__callbacks[connection] = [] self.__callbacks[connection].append(callback) ## Remove a signal connection. def removeConnection(self, service_path, object_path, interface, signal_name, callback): connection = (object_path, interface, signal_name) if connection not in self.__connected_signals: return self.__callbacks[connection].remove(callback) # Essentially, we do reference counting of the signal here. If the list # of connections for the specified signal becomes empty, also remove the # signal handler. This prevents us from listening on signals that are # not used. if not self.__callbacks[connection]: self.__connection.disconnect(service_path, object_path, interface, signal_name, self.handleSignal) self.__connected_signals.remove(connection) del self.__callbacks[connection] # Process a signal from DBus. @pyqtSlot(QDBusMessage) def handleSignal(self, message): connection = (message.path(), message.interface(), message.member()) if connection not in self.__callbacks: return for callback in self.__callbacks[connection]: callback(*message.arguments()) # Make an asynchronous DBus call. This will trigger __onAsyncCallFinished once it is done. def asyncCall(self, message, success_callback, error_callback): watcher = QDBusPendingCallWatcher(self.__connection.asyncCall(message)) watcher.finished.connect(self.__onAsyncCallFinished) self.__pending_async_calls[watcher] = (success_callback, error_callback) # Handle async call completion. @pyqtSlot(QDBusPendingCallWatcher) def __onAsyncCallFinished(self, watcher): assert watcher in self.__pending_async_calls success_callback = self.__pending_async_calls[watcher][0] error_callback = self.__pending_async_calls[watcher][1] del self.__pending_async_calls[watcher] reply = QDBusReply(watcher) if reply.isValid(): if success_callback: success_callback(reply.value()) else: if error_callback: error_callback(reply.error().message()) libCharon-4.13.0/Charon/Client/Request.py000066400000000000000000000150301413347326300201370ustar00rootroot00000000000000import enum import threading import uuid from typing import List, Dict, Any, Optional, Callable from .DBusInterface import DBusInterface ## Wrapper around the Charon DBus service that hides the DBus details. # # This class encapsulates all the data and information needed for # retrieving some data from a file supported by the Charon file service. # # It can be used to simplify dealing with the DBus service. class Request: # The request state. class State(enum.IntEnum): Initial = 0 # Request was created, but not started yet. Running = 1 # Request was started. Completed = 2 # Request completed successfully. Error = 3 # Request encountered an error. ## Constructor. # # \param file_path The path to a file to get data from. # \param virtual_paths A list of virtual paths with the data to retrieve. def __init__(self, file_path: str, virtual_paths: List[str]) -> None: self.__file_path = file_path self.__virtual_paths = virtual_paths self.__state = self.State.Initial self.__request_id = 0 self.__data = {} # type: Dict[str, Any] self.__error_string = "" self.__event = threading.Event() self.__request_data_callback = None # type: Optional[Callable[["Request", Dict[str, Any]], None]] self.__request_completed_callback = None # type: Optional[Callable[["Request"], None]] self.__request_error_callback = None # type: Optional[Callable[["Request", str], None]] ## Cleanup function. def __del__(self): if self.__state != self.State.Initial: self.stop() DBusInterface.disconnectSignal("requestData", self.__onRequestData) DBusInterface.disconnectSignal("requestCompleted", self.__onRequestCompleted) DBusInterface.disconnectSignal("requestError", self.__onRequestError) ## The file path for this request. @property def filePath(self) -> str: return self.__file_path ## The virtual paths for this request. @property def virtualPaths(self) -> List[str]: return self.__virtual_paths ## The state of this request. @property def state(self) -> State: return self.__state ## The data associated with this request. # # Note that this will be an empty dictionary until the request # completed. @property def data(self) -> Dict[str, Any]: return self.__data ## A description of the error that was encountered during the request. # # Note that this will be an empty string if there was no error. @property def errorString(self) -> str: return self.__error_string ## Set the callbacks that should be called while the request is running. # # Note: These parameters can only be passed as keyword arguments. # \param data The callback to call when data is received. Will be passed the request object and a dict with data. # \param completed The callback to call when the request has completed. Will be passed the request object. # \param error The callback to call when the request encountered an error. Will be passed the request object and a string describing the error. # def setCallbacks(self, *, data: Callable[["Request", Dict[str, Any]], None] = None, completed: Callable[["Request"], None] = None, error: Callable[["Request", str], None] = None) -> None: self.__request_data_callback = data self.__request_completed_callback = completed self.__request_error_callback = error ## Start the request. def start(self): if self.__state != self.State.Initial: return self.__request_id = str(uuid.uuid4()) DBusInterface.connectSignal("requestData", self.__onRequestData) DBusInterface.connectSignal("requestCompleted", self.__onRequestCompleted) DBusInterface.connectSignal("requestError", self.__onRequestError) self.__state = self.State.Running DBusInterface.callAsync("startRequest", self.__startSuccess, self.__startError, "ssas", self.__request_id, self.__file_path, self.__virtual_paths) ## Stop the request. # # Note that this may fail if the file service was already processing the request. def stop(self): if self.__state != self.State.Running: return DBusInterface.callAsync("cancelRequest", None, None, "s", self.__request_id) ## Wait until the request is finished. # # Warning! This method will block the calling thread until it is finished. The DBus implementations # require a running event loop for signal delivery to work. This means that if you block the main # loop with this method, you will deadlock since the completed signal is never received. def waitForFinished(self): if self.__state == self.State.Initial: self.start() self.__event.clear() self.__event.wait() def __startSuccess(self, start_success: bool): if not start_success: self.__startError("Could not start the request") return def __startError(self, error: str): self.__state = self.State.Error self.__error_string = error self.__event.set() if self.__request_error_callback: self.__request_error_callback(self, error) def __onRequestData(self, request_id: str, data: Dict[str, Any]): if self.__state != self.State.Running: return if self.__request_id != request_id: return self.__data.update(data) if self.__request_data_callback: self.__request_data_callback(self, data) def __onRequestCompleted(self, request_id: str): if self.__state != self.State.Running: return if self.__request_id != request_id: return self.__state = self.State.Completed if self.__request_completed_callback: self.__request_completed_callback(self) self.__event.set() def __onRequestError(self, request_id: str, error_string: str): if self.__request_id != request_id: return self.__state = self.State.Error self.__error_string = error_string if self.__request_error_callback: self.__request_error_callback(self, error_string) self.__event.set() def __repr__(self): return "".format(id = id(self), path = self.__file_path, virtual = self.__virtual_paths) libCharon-4.13.0/Charon/Client/__init__.py000066400000000000000000000000351413347326300202450ustar00rootroot00000000000000from .Request import Request libCharon-4.13.0/Charon/Client/test_glib.py000066400000000000000000000014211413347326300204620ustar00rootroot00000000000000import sys import dbus import dbus.mainloop.glib from gi.repository import GLib import Charon.Client if len(sys.argv) != 2: print("Usage: test.py [file]") exit(1) GLib.threads_init() dbus.mainloop.glib.threads_init() loop = GLib.MainLoop() dbus.set_default_main_loop(dbus.mainloop.glib.DBusGMainLoop()) request = Charon.Client.Request(sys.argv[1], ["/Metadata/thumbnail.png"]) request.setCallbacks(completed=lambda request: loop.quit()) request.start() loop.run() if request.state == Charon.Client.Request.State.Completed: print("Request Complete") print(request.data) elif request.state == Charon.Client.Request.State.Error: print("Request Error") print(request.errorString) else: print("Request did not finish properly") print(request.state) libCharon-4.13.0/Charon/Client/test_qt.py000066400000000000000000000012371413347326300201760ustar00rootroot00000000000000import sys from PyQt5.QtCore import QCoreApplication, QTimer import Charon.Client if len(sys.argv) != 2: print("Usage: test.py [file]") exit(1) app = QCoreApplication(sys.argv) request = Charon.Client.Request(sys.argv[1], ["/Metadata/thumbnail.png"]) request.start() while(request.state == Charon.Client.Request.State.Running): app.processEvents() if request.state == Charon.Client.Request.State.Completed: print("Request Complete") print(request.data) elif request.state == Charon.Client.Request.State.Error: print("Request Error") print(request.errorString) else: print("Request did not finish properly") print(request.state) libCharon-4.13.0/Charon/FileInterface.py000066400000000000000000000176701413347326300200050ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. from typing import Any, Dict, List, IO, Optional, Callable from Charon.OpenMode import OpenMode ## An interface for accessing files. # # This interface is designed to be able to access 3D-printing related files, # and for container-type files to access the resources therein. class FileInterface: stream_handler = open # type: Callable[[str, str], IO[bytes]] mime_type = "" ## Opens a file for reading or writing. # # After opening the file, this instance will represent that file from then # on, meaning that the metadata getters/setters and the streams will be # functioning on that file. # \param path The path to the file on local disk, relative or absolute. # \param mode The mode with which to open the file (see OpenMode). def open(self, path: str, mode: OpenMode = OpenMode.ReadOnly) -> None: raise NotImplementedError("The open() function of " + self.__class__.__qualname__ + " is not implemented.") ## Opens a stream for reading or writing. # # After opening the stream, this instance will represent that stream from # then on, meaning that the metadata getters/setters and the streams will # be functioning on that stream. # \param stream The stream to read from or write to. # \param mime The MIME type of the stream. This determines what implementation is used to read/write it. # \param mode The mode with which to open the file (see OpenMode). def openStream(self, stream: IO[bytes], mime: str, mode: OpenMode = OpenMode.ReadOnly) -> None: raise NotImplementedError("The openStream() function of " + self.__class__.__qualname__ + " is not implemented.") ## Closes the opened file, releasing the resources in use for it. # # After the file is closed, this instance can no longer be used until the ``open`` method is called again. def close(self) -> None: raise NotImplementedError("The close() function of " + self.__class__.__qualname__ + " is not implemented.") ## Ensures that no buffered data is still pending to be read or written. def flush(self) -> None: raise NotImplementedError("The flush() function of " + self.__class__.__qualname__ + " is not implemented.") ## Returns a list of all resources and metadata in the file. def listPaths(self) -> List[str]: raise NotImplementedError("The listPaths() function of " + self.__class__.__qualname__ + " is not implemented.") ## Gets the data stored at the specified virtual path and all its descendants. # # The returned dictionary may contain normal resources as well as # metadata. If it is a normal resource, the value will contain the # serialised data (either ``bytes`` or ``str``, depending on whether the # file opens in binary mode or not). If it is metadata, all metadata keys # under the specified path are returned (all descendants in the tree). If # there is no metadata and no resource under the selected virtual path, an # empty dictionary is returned. # \param virtual_path The path inside the file to get the data from. # \return The data and metadata under the specified virtual path. def getData(self, virtual_path: str) -> Dict[str, Any]: raise NotImplementedError("The getData() function of " + self.__class__.__qualname__ + " is not implemented.") ## Sets the data of several virtual paths at once. # # The ``data`` parameter provides a dictionary mapping virtual paths to # the new data that should be provided in the path. def setData(self, data: Dict[str, Any]) -> None: raise NotImplementedError("The setData() function of " + self.__class__.__qualname__ + " is not implemented.") ## Gets metadata entries in the opened file. # # The metadata is a dictionary, where the keys are virtual paths in the # subtree of the resource tree specified by ``virtual_path``. For # instance, when requesting the metadata of the resource with virtual path # ``/metadata``, this function could return a dictionary containing: # * ``/metadata/size``: 12354 # * ``/metadata/toolpath/default/size``: 12000 # * ``/metadata/toolpath/default/machine_type``: ``ultimaker3`` # * ``/metadata/toolpath/default/print_time``: 121245 # * ``/metadata/toolpath/default/print_size``: (0, 0, 0) x (100, 100, 100) # # But a subtree can be requested as well, such as # ``/metadata/toolpath/default/size``, which would then return a # dictionary containing only the key ``/metadata/toolpath/default/size`` # and its value, because there are no other subitems in that subtree. # # If there is no metadata in the requested path, an empty dictionary is # returned. # \param virtual_path The subtree of metadata entries to get the metadata # of. # \return A dictionary of all the metadata entries in the selected # subtree. def getMetadata(self, virtual_path: str) -> Dict[str, Any]: raise NotImplementedError("The getMetadata() function of " + self.__class__.__qualname__ + " is not implemented.") ## Changes some metadata entries in the opened file. # # The provided dictionary must have the full virtual paths of the metadata # entries it wants to change as its keys, and the new values along with # every key. # # If a metadata entry didn't exist yet, it is created. # # If a metadata entry by cannot be changed (such as the file size of a # resource) then a ``ReadOnlyError`` must be raised for that resource, and # none of the changes of this function call may be applied (or everything # must be undone). # \param metadata A dictionary of metadata entries to change. # \raises ReadOnlyError A metadata entry cannot be changed (such as the # file size of a resource). def setMetadata(self, metadata: Dict[str, Any]) -> None: raise NotImplementedError("The setMetadata() function of " + self.__class__.__qualname__ + " is not implemented.") ## Gets an I/O stream to the resource or metadata at the specified virtual # path. # # This stream may be a normal resource or it may be metadata. If it is # metadata, a stream will be returned in the form of a JSON document # (encoded in UTF-8 for binary streams) containing all the metadata that # would be returned by the getMetadata method. # # Whether the returned stream is an input or an output stream depends on # the mode that was provided in the ``open`` method. This determines # whether you can read from and/or write to the stream. # # If a resource didn't exist and you can write, the resource is created. # \param virtual_path The virtual path to the resource that you want to # read or write. # \raises ReadOnlyError The resource doesn't exist and there are no write # permissions to create it. def getStream(self, virtual_path: str) -> IO[bytes]: raise NotImplementedError("The getStream() function of " + self.__class__.__qualname__ + " is not implemented.") ## Gets a bytes representation of the file. # # Resources inside the file are not supported by this method. Use # ``getStream`` for that. # \param offset The number of bytes to skip at the beginning of the file. # \param count The maximum number of bytes to return. If the file is # longer than this, it is truncated. If the file is shorter than this, # fewer bytes than this might be returned. If not specified, the entire # file will be returned except the initial offset. # \return bytes A bytes array representing the file or a part of it. def toByteArray(self, offset: int = 0, count: int = -1) -> bytes: raise NotImplementedError("The toByteArray() function of " + self.__class__.__qualname__ + " is not implemented.")libCharon-4.13.0/Charon/OpenMode.py000066400000000000000000000012521413347326300170000ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. import enum #The class in this file is an enum. ## The possible purposes for which you could open a file. # # You could always open a file in read-write mode, but it's best practice to # open a file in specific read or write only modes if only one of the two is # needed. This will prevent the programmer from accidentally modifying the # file and may trigger some operating systems to treat the file lock # differently. class OpenMode(enum.Enum): ## The file can only be read from. ReadOnly = "r" ## The file can only be written to. WriteOnly = "w"libCharon-4.13.0/Charon/ReadOnlyError.py000066400000000000000000000017641413347326300200310ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. ## Exception to indicate that an attempt was made to write to a resource that # is read-only. # # Normally this sort of thing would be a ``PermissionError`` (the built-in # Python exception), but we want to be able to distinguish between these # errors ordinary ``PermissionErrors`` raised by the file system not having # access to that file. class ReadOnlyError(PermissionError): ## Creates the exception instance. # \param virtual_path The resource that could not be written to. If not # provided, an empty string is used which indicates that the entire file # could not be written to. def __init__(self, virtual_path: str = "") -> None: self.virtual_path = virtual_path ## Provides a human-readable version of this error for in the stack trace. def __repr__(self) -> str: return "ReadOnlyError({resource})".format(resource = self.virtual_path)libCharon-4.13.0/Charon/Service/000077500000000000000000000000001413347326300163205ustar00rootroot00000000000000libCharon-4.13.0/Charon/Service/FileService.py000066400000000000000000000073561413347326300211050ustar00rootroot00000000000000import dbus import logging import RequestQueue log = logging.getLogger(__name__) ## The main interface for the Charon file service. # # This contains the main interface definition for the Charon file service. # It is exposed over DBus as the "nl.ultimaker.charon" service, with # "/nl/ultimaker/charon" as its object path and all functions registered # in the "nl.ultimaker.charon" interface name. # # The file service maintains a queue of jobs that need to be processed. # See RequestQueue for details on this process. # # Note: This class does not currently use type hinting since type hints, # dbus-python decorators and Python 3.4 do not mix well. class FileService(dbus.service.Object): def __init__(self, dbus_bus: dbus.Bus) -> None: super().__init__( bus_name = dbus.service.BusName("nl.ultimaker.charon", dbus_bus), object_path = "/nl/ultimaker/charon" ) log.debug("FileService initialized") self.__queue = RequestQueue.RequestQueue() ## Start a request for data from a file. # # This function will start a request for data from a certain file. # It will be processed in a separate thread. # # When the request has finished, `requestFinished` will be emitted. # # \param request_id A unique identifier to track this request with. # \param file_path The path to a file to load. # \param virtual_paths A list of virtual paths that define what set of data to retrieve. # # \return A boolean indicating whether the request was successfully started. @dbus.decorators.method("nl.ultimaker.charon", "ssas", "b") def startRequest(self, request_id, file_path, virtual_paths): log.debug("Received request {id} for {virtual} from {path}".format(id = request_id, virtual = virtual_paths, path = file_path)) request = RequestQueue.Request(self, request_id, file_path, virtual_paths) return self.__queue.enqueue(request) ## Cancel a pending request for data. # # This will cancel a request that was previously posted. # # Note that if the request is already being processed, the request will not be # canceled. If the cancel was successful, `requestError` will be emitted with the # specified request and an error string describing it was canceled. # # \param request_id The ID of the request to cancel. @dbus.decorators.method("nl.ultimaker.charon", "s", "") def cancelRequest(self, request_id): log.debug("Cancel request '{id}'".format(id = request_id)) if self.__queue.dequeue(request_id): self.requestError(request_id, "Request canceled") ## Emitted whenever data for a request is available. # # This will be emitted while a request is processing and requested data has become # available. # # \param request_id The ID of the request that data is available for. # \param data A dictionary with virtual paths and data for those paths. @dbus.decorators.signal("nl.ultimaker.charon", "sa{sv}") def requestData(self, request_id, data): pass ## Emitted whenever a request for data has been completed. # # This signal will be emitted once a request is completed successfully. # # \param request_id The ID of the request that completed. @dbus.decorators.signal("nl.ultimaker.charon", "s") def requestCompleted(self, request_id): pass ## Emitted whenever a request that is processing encounters an error. # # \param request_id The ID of the request that encountered an error. # \param error_string A string describing the error. @dbus.decorators.signal("nl.ultimaker.charon", "ss") def requestError(self, request_id, error_string): pass libCharon-4.13.0/Charon/Service/RequestQueue.py000066400000000000000000000140271413347326300213330ustar00rootroot00000000000000import queue import threading import logging import dbus from typing import List, Dict, Any import FileService import Charon.VirtualFile import Charon.OpenMode log = logging.getLogger(__name__) ## A request for data that needs to be processed. # # Each request will be processed by a worker thread to actually perform the data # retrieval. class Request: ## Constructor. # # \param file_service The main FileService object. Used to emit signals. # \param request_id The ID used to identify this request. # \param file_path A path to a file to retrieve data from. # \param virtual_paths The virtual paths to retrieve for this request. def __init__(self, file_service: FileService.FileService, request_id: str, file_path: str, virtual_paths: List[str]) -> None: self.file_service = file_service self.file_path = file_path self.virtual_paths = virtual_paths self.request_id = request_id # This is used a workaround for limitations of Python's Queue class. # Queue does not implement a "remove arbitrary item" method. So instead, # keep a removed request in the queue and set this flag to true, after # which a worker thread can dispose of the object when it encounters # the request. self.should_remove = False ## Perform the actual data retrieval. # # This is a potentially long-running operation that should be handled by a # thread. def run(self): try: virtual_file = Charon.VirtualFile.VirtualFile() virtual_file.open(self.file_path, Charon.OpenMode.OpenMode.ReadOnly) for path in self.virtual_paths: data = virtual_file.getData(path) for key, value in data.items(): if isinstance(value, bytes): data[key] = dbus.ByteArray(value) # dbus-python is stupid and we need to convert the entire nested dictionary # into something it understands. data = self._convertDictionary(data) self.file_service.requestData(self.request_id, data) virtual_file.close() self.file_service.requestCompleted(self.request_id) except Exception as e: log.log(logging.DEBUG, "", exc_info = 1) self.file_service.requestError(self.request_id, str(e)) # Helper for dbus-python to convert a nested dict to a nested dict. # # Yes, really, apparently dbus-python does some really stupid things with dictionaries # making this necessary. def _convertDictionary(self, dictionary: Dict[str, Any]) -> dbus.Dictionary: result = dbus.Dictionary({}, signature = "sv") for key, value in dictionary.items(): key = str(key) # Since we are sending a dict of str, Any, make sure the keys are strings. if isinstance(value, bytes): # Workaround dbus-python being stupid and not realizing that a bytes object # should be sent as byte array, not as string. result[key] = dbus.ByteArray(value) elif isinstance(value, dict): result[key] = self._convertDictionary(value) else: result[key] = value return result ## A queue of requests that need to be processed. # # This class will maintain a queue of requests to process along with the worker threads # to process them. It processes the request in LIFO order. class RequestQueue: def __init__(self): self.__queue = queue.LifoQueue(self.__maximum_queue_size) # This map is used to keep track of which requests we already received. # This is mostly intended to be able to cancel requests that are # in the queue. self.__request_map = {} self.__workers = [] for i in range(self.__worker_count): worker = threading.Thread(target = self.__worker_thread_run, daemon = True) worker.start() self.__workers.append(worker) ## Add a new request to the queue. # # \param request The request to add. # # \return True if successful, False if the request could not be enqueued for some reason. def enqueue(self, request: Request): if(request.request_id in self.__request_map): log.debug("Tried to enqueue a request with ID {id} which is already in the queue".format(id = request.request_id)) return False try: self.__queue.put(request, block = False) except queue.Full: log.debug("Tried to enqueue a request with ID {id} but the queue is full".format(id = request.request_id)) return False self.__request_map[request.request_id] = request return True ## Remove a request from the queue. # # \param request_id The ID of the request to remove. # # \return True if the request was successfully removed, False if the request was not in the queue. def dequeue(self, request_id: str): if request_id not in self.__request_map: log.debug("Unable to remove request with ID {id} which is not in the queue".format(id = request_id)) return False self.__request_map[request_id].should_remove = True return True ## Take the next request off the queue. # # Note that this method will block if there are no current requests on the queue. # # \return The next request on the queue. def takeNext(self) -> Request: request = self.__queue.get() del self.__request_map[request.request_id] return request # Implementation of the worker thread run method. def __worker_thread_run(self): while True: request = self.takeNext() if request.should_remove: continue try: request.run() except Exception as e: log.log(logging.DEBUG, "Request caused an uncaught exception when running!", exc_info = 1) __maximum_queue_size = 100 __worker_count = 2 libCharon-4.13.0/Charon/Service/__init__.py000066400000000000000000000000451413347326300204300ustar00rootroot00000000000000from .FileService import FileService libCharon-4.13.0/Charon/Service/main.py000066400000000000000000000016331413347326300176210ustar00rootroot00000000000000 import os import logging import dbus.service import dbus.mainloop.glib from typing import Dict, Any from gi.repository import GLib import Charon.Service # Very basic service main loop built with GLib. GLib.threads_init() dbus.mainloop.glib.threads_init() config = {} # type: Dict[str, Any] if os.environ.get("CHARON_DEBUG", "0") == "1": config["level"] = logging.DEBUG else: config["level"] = logging.WARNING config["format"] = "%(asctime)s | %(levelname)s | %(name)s:%(lineno)d@%(funcName)s | %(message)s" logging.basicConfig(**config) _loop = GLib.MainLoop() # Use a single bus object for all dbus communication. if os.environ.get("CHARON_USE_SESSION_BUS", "1") == "1": _bus = dbus.SessionBus(private=True, mainloop=dbus.mainloop.glib.DBusGMainLoop()) else: _bus = dbus.SystemBus(private=True, mainloop=dbus.mainloop.glib.DBusGMainLoop()) _service = Charon.Service.FileService(_bus) _loop.run() libCharon-4.13.0/Charon/VirtualFile.py000066400000000000000000000056461413347326300175330ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. import os from Charon.FileInterface import FileInterface # The interface we're implementing. from Charon.OpenMode import OpenMode #To open local files with the selected open mode. # The supported file types. from Charon.filetypes.UltimakerFormatPackage import UltimakerFormatPackage from Charon.filetypes.GCodeFile import GCodeFile from Charon.filetypes.GCodeGzFile import GCodeGzFile from Charon.filetypes.GCodeSocket import GCodeSocket extension_to_mime = { ".ufp": "application/x-ufp", ".gcode": "text/x-gcode", ".gz": "text/x-gcode-gz", ".gcode.gz": "text/x-gcode-gz", ".gsock": "text/x-gcode-socket" } mime_to_implementation = { "application/x-ufp": UltimakerFormatPackage, "text/x-gcode": GCodeFile, "text/x-gcode-gz": GCodeGzFile, "text/x-gcode-socket": GCodeSocket } ## A facade for a file object. # # This facade finds the correct implementation based on the MIME type of the # file it needs to open. class VirtualFile(FileInterface): def __init__(self): self._implementation = None def open(self, path, mode = OpenMode.ReadOnly, *args, **kwargs): _, extension = os.path.splitext(path) if extension not in extension_to_mime: raise IOError("Unknown extension \"{extension}\".".format(extension = extension)) mime = extension_to_mime[extension] implementation = mime_to_implementation[mime] return self.openStream(implementation.stream_handler(path, mode.value + "b"), mime, mode, *args, **kwargs) def openStream(self, stream, mime, mode = OpenMode.ReadOnly, *args, **kwargs): self._implementation = mime_to_implementation[mime]() return self._implementation.openStream(stream, mime, mode, *args, **kwargs) def close(self, *args, **kwargs): if self._implementation is None: raise IOError("Can't close a file before it's opened.") result = self._implementation.close(*args, **kwargs) self._implementation = None # You have to open a file again, which might need a different implementation. return result ## Causes all calls to functions that aren't defined in this class to be # passed through to the implementation. def __getattribute__(self, item): if item == "open" or item == "openStream" or item == "close" or item == "__del__" or item == "_implementation": # Attributes that VirtualFile overwrites should be called normally. return object.__getattribute__(self, item) if not object.__getattribute__(self, "_implementation"): raise IOError("Can't use '{attribute}' before a file is opened.".format(attribute = item)) return getattr(self._implementation, item) ## When the object is deleted, close the file. def __del__(self): if self._implementation is not None: self.close() libCharon-4.13.0/Charon/WriteOnlyError.py000066400000000000000000000017661413347326300202520ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. ## Exception to indicate that an attempt was made to read from a resource that # is write-only. # # Normally this sort of thing would be a ``PermissionError`` (the built-in # Python exception), but we want to be able to distinguish between these # errors ordinary ``PermissionErrors`` raised by the file system not having # access to that file. class WriteOnlyError(PermissionError): ## Creates the exception instance. # \param virtual_path The resource that could not be read from. If not # provided, an empty string is used which indicates that the entire file # could not be read from. def __init__(self, virtual_path: str = "") -> None: self.virtual_path = virtual_path ## Provides a human-readable version of this error for in the stack trace. def __repr__(self) -> str: return "WriteOnlyError({resource})".format(resource = self.virtual_path)libCharon-4.13.0/Charon/__init__.py000066400000000000000000000001451413347326300170310ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. libCharon-4.13.0/Charon/filetypes/000077500000000000000000000000001413347326300167245ustar00rootroot00000000000000libCharon-4.13.0/Charon/filetypes/GCodeFile.py000066400000000000000000000244611413347326300210660ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. import ast from typing import Any, Dict, IO, List, Optional, Union from Charon.FileInterface import FileInterface from Charon.OpenMode import OpenMode def isAPositiveNumber(a: str) -> bool: try: number = float(repr(a)) return number >= 0 except: bool_a = False return bool_a class GCodeFile(FileInterface): mime_type = "text/x-gcode" MaximumHeaderLength = 100 def __init__(self) -> None: self.__stream = None # type: Optional[IO[bytes]] self.__metadata = {} # type: Dict[str, Any] def openStream(self, stream: IO[bytes], mime: str, mode: OpenMode = OpenMode.ReadOnly) -> None: if mode != OpenMode.ReadOnly: raise NotImplementedError() self.__stream = stream self.__metadata = {} self.__metadata = self.parseHeader(self.__stream, prefix = "/metadata/toolpath/default/") @staticmethod def parseHeader(stream: IO[bytes], *, prefix: str = "") -> Dict[str, Any]: try: metadata = {} # type: Dict[str, Any] line_number = 0 for line_number, bytes_line in enumerate(stream): if line_number > GCodeFile.MaximumHeaderLength: break line = bytes_line.decode("utf-8") if line.startswith(";START_OF_HEADER"): continue elif line.startswith(";LAYER") or line.startswith(";END_OF_HEADER"): break elif line.startswith(";HEADER_VERSION"): # Header version is a number but should not be parsed as number, so special case it. metadata["header_version"] = line.split(":")[1].strip() elif line.startswith(";") and ":" in line: key, value = line[1:].split(":") key = key.strip().lower() value = value.strip() try: value = ast.literal_eval(value.strip()) except: pass key_elements = key.split(".") GCodeFile.__insertKeyValuePair(metadata, key_elements, value) if stream.seekable(): stream.seek(0) flavor = metadata.get("flavor", None) if flavor == "Griffin": if metadata["header_version"] != "0.1": raise InvalidHeaderException("Unsupported Griffin header version: {0}".format(metadata["header_version"])) GCodeFile.__validateGriffinHeader(metadata) GCodeFile.__cleanGriffinHeader(metadata) elif flavor == "UltiGCode": metadata["machine_type"] = "ultimaker2" else: raise InvalidHeaderException("Flavor must be defined!") if prefix: prefixed_metadata = {} for key, value in metadata.items(): prefixed_metadata[prefix + key] = value metadata = prefixed_metadata return metadata except Exception as e: raise InvalidHeaderException("Unable to parse the header. An exception occured; %s" % e) ## Add a key-value pair to the metadata dictionary. # Splits up key each element to it's own dictionary. # @param metadata Metadata collection # @param key_elements List of separate key name elements # @param value Key value @staticmethod def __insertKeyValuePair( metadata: Dict[str, Any], key_elements: Any, value: Any ) -> Any: if not key_elements: return value sub_dict = {} if key_elements[0] in metadata: sub_dict = metadata[key_elements[0]] metadata[key_elements[0]] = GCodeFile.__insertKeyValuePair(sub_dict, key_elements[1:], value) return metadata def getData(self, virtual_path: str) -> Dict[str, Any]: assert self.__stream is not None if virtual_path.startswith("/metadata"): result = {} for key, value in self.__metadata.items(): if key.startswith(virtual_path): result[key] = value return result if virtual_path == "/toolpath" or virtual_path == "/toolpath/default": return { virtual_path: self.__stream.read() } return {} ## Cleans a parsed GRIFFIN flavoured GCODE header. @staticmethod def __cleanGriffinHeader(metadata: Dict[str, Any]) -> None: metadata["machine_type"] = metadata["target_machine"]["name"] del metadata["target_machine"] if GCodeFile.__isAvailable(metadata, ["time"]): GCodeFile.__insertKeyValuePair(metadata, ["print", "time"], metadata["time"]) # del metadata["time"] # We want to delete the old key, but it's behavior of how the code was. GCodeFile.__insertKeyValuePair(metadata, ["print", "min_size"], metadata["print"]["size"]["min"]) GCodeFile.__insertKeyValuePair(metadata, ["print", "max_size"], metadata["print"]["size"]["max"]) del metadata["print"]["size"] for key, value in metadata["extruder_train"].items(): GCodeFile.__insertKeyValuePair(metadata, ["extruders", int(key)], value) del metadata["extruder_train"] ## Checks if a path to a key is available # @param metadata Metadata collection to check for the presence of the key # @param keys List of key elements describing the path to a value. If a key element is a list, then all the elements # must exist on the location of that key element # @return True if the key is available and not empty @staticmethod def __isAvailable(metadata: Dict[str, Any], keys: List[Any]) -> bool: if not keys: return True key = keys[0] if isinstance(key, list): key_is_valid = True for sub_key in key: key_is_valid = key_is_valid and GCodeFile.__isAvailable(metadata, [sub_key] + [keys[1:]]) else: key_is_valid = key in metadata and metadata[key] is not None and not str(metadata[key]) == "" key_is_valid = key_is_valid and GCodeFile.__isAvailable(metadata[key], keys[1:]) return key_is_valid ## Validates a parsed GRIFFIN flavoured GCODE header. # Will raise an InvalidHeader exception when the header is invalid. # @param metadata Key/value dictionary based on the header. @staticmethod def __validateGriffinHeader(metadata: Dict[str, Any]) -> None: # Validate target settings if not GCodeFile.__isAvailable(metadata, ["target_machine", "name"]): raise InvalidHeaderException("TARGET_MACHINE.NAME must be set") # Validate generator settings if not GCodeFile.__isAvailable(metadata, ["generator", "name"]): raise InvalidHeaderException("GENERATOR.NAME must be set") if not GCodeFile.__isAvailable(metadata, ["generator", "version"]): raise InvalidHeaderException("GENERATOR.VERSION must be set") if not GCodeFile.__isAvailable(metadata, ["generator", "build_date"]): raise InvalidHeaderException("GENERATOR.BUILD_DATE must be set") # Validate build plate temperature if not GCodeFile.__isAvailable(metadata, ["build_plate", "initial_temperature"]) or \ not isAPositiveNumber(metadata["build_plate"]["initial_temperature"]): raise InvalidHeaderException("BUILD_PLATE.INITIAL_TEMPERATURE must be set and be a positive real") # Validate dimensions if not GCodeFile.__isAvailable(metadata, ["print", "size", "min", ["x", "y", "z"]]): raise InvalidHeaderException("PRINT.SIZE.MIN.[x,y,z] must be set. Ensure all three are defined.") if not GCodeFile.__isAvailable(metadata, ["print", "size", "max", ["x", "y", "z"]]): raise InvalidHeaderException("PRINT.SIZE.MAX.[x,y,z] must be set. Ensure all three are defined.") # Validate print time print_time = -1 if GCodeFile.__isAvailable(metadata, ["print", "time"]): print_time = int(metadata["print"]["time"]) elif GCodeFile.__isAvailable(metadata, ["time"]): print_time = int(metadata["time"]) else: raise InvalidHeaderException("TIME or PRINT.TIME must be set") if print_time < 0: raise InvalidHeaderException("Print Time should be a positive integer") # Validate extruder train for index in range(0, 10): index_str = str(index) if GCodeFile.__isAvailable(metadata, ["extruder_train", index_str]): if not GCodeFile.__isAvailable(metadata, ["extruder_train", index_str, "nozzle", "diameter"]) or \ not isAPositiveNumber(metadata["extruder_train"][index_str]["nozzle"]["diameter"]): raise InvalidHeaderException( "extruder_train.{}.nozzle.diameter must be defined and be a positive real".format(index)) if not GCodeFile.__isAvailable(metadata, ["extruder_train", index_str, "material", "volume_used"]) or \ not isAPositiveNumber(metadata["extruder_train"][index_str]["material"]["volume_used"]): raise InvalidHeaderException( "extruder_train.{}.material.volume_used must be defined and positive".format(index)) if not GCodeFile.__isAvailable(metadata, ["extruder_train", index_str, "initial_temperature"]) or \ not isAPositiveNumber(metadata["extruder_train"][index_str]["initial_temperature"]): raise InvalidHeaderException( "extruder_train.{}.initial_temperature must be defined and positive".format(index)) def getStream(self, virtual_path: str) -> IO[bytes]: assert self.__stream is not None if virtual_path != "/toolpath" and virtual_path != "/toolpath/default": raise NotImplementedError("G-code files only support /toolpath as stream") return self.__stream def close(self) -> None: assert self.__stream is not None self.__stream.close() class InvalidHeaderException(Exception): pass libCharon-4.13.0/Charon/filetypes/GCodeGzFile.py000066400000000000000000000005001413347326300213530ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. import gzip from Charon.filetypes.GCodeFile import GCodeFile class GCodeGzFile(GCodeFile): stream_handler = gzip.open mime_type = "text/x-gcode-gz" def __init__(self) -> None: super().__init__() libCharon-4.13.0/Charon/filetypes/GCodeSocket.py000066400000000000000000000044531413347326300214360ustar00rootroot00000000000000# Copyright (c) 2021 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. import socket import struct from io import BytesIO, SEEK_SET, SEEK_CUR from typing import Any, Dict, IO, Optional, List from Charon.filetypes.GCodeFile import GCodeFile from urllib.parse import urlparse ## This class is used to read GCode stream that are served # dynamically over a TCP connection. class SocketFileStream(BytesIO): def __init__(self, sock_object: socket.socket) -> None: super().__init__() self.current_line = 0 self.__socket = sock_object def seekable(self) -> bool: return True def seek(self, offset: int, whence: Optional[int] = None) -> int: if whence is None or whence == SEEK_SET: self.current_line = offset elif whence == SEEK_CUR: self.current_line += offset else: raise ValueError('Unsupported whence mode in seek: %d' % whence) return offset def readline(self, _size: int = -1) -> bytes: self.__socket.send(struct.pack('>I', self.current_line)) line = b'' char = b'' while char != b'\n': char = self.__socket.recv(1) line += char self.current_line += 1 return line def read(self, _size: int = -1) -> bytes: raise NotImplementedError("Only readline has been implemented") def readlines(self, _hint: int = -1) -> List[bytes]: raise NotImplementedError("Only readline has been implemented") def tell(self) -> int: raise NotImplementedError("Only readline has been implemented") def close(self) -> None: self.__socket.close() def __iter__(self): return self def __next__(self): return self.readline() class GCodeSocket(GCodeFile): mime_type = "text/x-gcode-socket" MaximumHeaderLength = 100 def __init__(self) -> None: super().__init__() self.__stream = None # type: Optional[IO[bytes]] self.__metadata = {} # type: Dict[str, Any] self.__sock = None @staticmethod def stream_handler(path: str, mode: str) -> IO: url = urlparse(path) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((url.hostname, 1337)) return SocketFileStream(sock) libCharon-4.13.0/Charon/filetypes/OpenPackagingConvention.py000066400000000000000000000737531413347326300240660ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. from collections import OrderedDict # To specify the aliases in order. from io import BytesIO import json # The metadata format. import re # To find the path aliases. from typing import Any, Dict, List, IO, Optional import xml.etree.ElementTree as ET # For writing XML manifest files. import zipfile from Charon.FileInterface import FileInterface # The interface we're implementing. from Charon.OpenMode import OpenMode # To detect whether we want to read and/or write to the file. from Charon.ReadOnlyError import ReadOnlyError # To be thrown when trying to write while in read-only mode. from Charon.WriteOnlyError import WriteOnlyError # To be thrown when trying to read while in write-only mode. from Charon.filetypes.GCodeFile import GCodeFile # Required for fallback G-Code header parsing. ## A container file type that contains multiple 3D-printing related files that # belong together. class OpenPackagingConvention(FileInterface): # Some constants related to this format. _xml_header = ET.ProcessingInstruction("xml", "version=\"1.0\" encoding=\"UTF-8\"") # Header element being put atop every XML file. _content_types_file = "/[Content_Types].xml" # Where the content types file is. _global_metadata_file = "/Metadata/OPC_Global.json" # Where the global metadata file is. _opc_metadata_relationship_type = "http://schemas.ultimaker.org/package/2018/relationships/opc_metadata" # Unique identifier of the relationship type that relates OPC metadata to files. _metadata_prefix = "/metadata" _aliases = OrderedDict([]) # type: Dict[str, str] # A standard OPC file doest not have default aliases. These must be implemented in inherited classes. mime_type = "application/x-opc" ## Initialises the fields of this class. def __init__(self) -> None: self._mode = None # type: Optional[OpenMode] # Whether we're in read or write mode. self._stream = None # type: Optional[IO[bytes]] # The currently open stream. self._zipfile = None # type: Optional[zipfile.ZipFile] # The zip interface to the currently open stream. self._metadata = {} # type: Dict[str, Any] # The metadata in the currently open file. self._content_types_element = None # type: Optional[ET.Element] # An XML element holding all the content types. self._relations = {} # type: Dict[str, ET.Element] # For each virtual path, a relations XML element (which is left out of the file if empty). self._open_bytes_streams = {} # type: Dict[str, IO[bytes]] # With old Python versions, the currently open BytesIO streams that need to be flushed, by their virtual path. # The zipfile module may only have one write stream open at a time. So when you open a new stream, close the previous one. self._last_open_path = None # type: Optional[str] self._last_open_stream = None # type: Optional[IO[bytes]] def openStream(self, stream: IO[bytes], mime: str = "application/x-opc", mode: OpenMode = OpenMode.ReadOnly) -> None: self._mode = mode self._stream = stream # A copy in case we need to rewind for toByteArray. We should mostly be reading via self._zipfile. self._zipfile = zipfile.ZipFile(self._stream, self._mode.value, compression=zipfile.ZIP_DEFLATED) self._readContentTypes() # Load or create the content types element. self._readRels() # Load or create the relations. self._readMetadata() # Load the metadata, if any. def close(self) -> None: if not self._stream: raise ValueError("This file is already closed.") if self._zipfile is None: return self.flush() self._zipfile.close() def flush(self) -> None: if not self._stream: raise ValueError("Can't flush a closed file.") assert self._zipfile is not None if self._mode == OpenMode.ReadOnly: return # No need to flush reading of zip archives as they are blocking calls. if self._last_open_stream is not None and self._last_open_path not in self._open_bytes_streams: self._last_open_stream.close() # If using old Python versions (<= 3.5), the write streams were kept in memory to be written all at once when flushing. for virtual_path, stream in self._open_bytes_streams.items(): stream.seek(0) self._zipfile.writestr(virtual_path, stream.read()) stream.close() self._writeMetadata() # Metadata must be updated first, because that adds rels and a content type. self._writeContentTypes() self._writeRels() def listPaths(self) -> List[str]: if not self._stream: raise ValueError("Can't list the paths in a closed file.") paths = [self._zipNameToVirtualPath(zip_name) for zip_name in self._zipfile.namelist()] return list(self._metadata.keys()) + paths def getData(self, virtual_path: str) -> Dict[str, Any]: if not self._stream: raise ValueError("Can't get data from a closed file.") assert self._zipfile is not None if self._mode == OpenMode.WriteOnly: raise WriteOnlyError(virtual_path) result = {} # type: Dict[str, Any] if virtual_path.startswith(self._metadata_prefix): result = self.getMetadata(virtual_path[len(self._metadata_prefix):]) else: canonical_path = self._processAliases(virtual_path) if self._resourceExists(canonical_path): result[virtual_path] = self.getStream( canonical_path).read() # In case of a name clash, the file wins. But that shouldn't be possible. return result def setData(self, data: Dict[str, Any]) -> None: if not self._stream: raise ValueError("Can't change the data in a closed file.") if self._mode == OpenMode.ReadOnly: raise ReadOnlyError() for virtual_path, value in data.items(): if virtual_path.startswith( self._metadata_prefix): # Detect metadata by virtue of being in the Metadata folder. self.setMetadata({virtual_path: value[len(self._metadata_prefix):]}) else: # Virtual file resources. self.getStream(virtual_path).write(value) def getMetadata(self, virtual_path: str) -> Dict[str, Any]: if not self._stream: raise ValueError("Can't get metadata from a closed file.") assert self._zipfile is not None if self._mode == OpenMode.WriteOnly: raise WriteOnlyError(virtual_path) canonical_path = self._processAliases(virtual_path) # Find all metadata that begins with the specified virtual path! result = {} if canonical_path in self._metadata: # The exact match. result[self._metadata_prefix + virtual_path] = self._metadata[canonical_path] for entry_path, value in self._metadata.items(): # We only want to match subdirectories of the provided virtual paths. # So if you provide "/foo" then we don't want to match on "/foobar" # but we do want to match on "/foo/zoo". This is why we check if they # start with the provided virtual path plus a slash. if entry_path.startswith(canonical_path + "/"): # We need to return the originally requested alias, so replace the canonical path with the virtual path. result[self._metadata_prefix + virtual_path + "/" + entry_path[len(canonical_path) + 1:]] = value # If requesting the size of a file. if canonical_path.endswith("/size"): requested_resource = canonical_path[:-len("/size")] if self._resourceExists(requested_resource): result[self._metadata_prefix + virtual_path] = self._zipfile.getinfo( requested_resource.strip("/")).file_size return result def setMetadata(self, metadata: Dict[str, Any]) -> None: if not self._stream: raise ValueError("Can't change metadata in a closed file.") if self._mode == OpenMode.ReadOnly: raise ReadOnlyError() metadata = {self._processAliases(virtual_path): metadata[virtual_path] for virtual_path in metadata} self._metadata.update(metadata) def getStream(self, virtual_path: str) -> IO[bytes]: if not self._stream: raise ValueError("Can't get a stream from a closed file.") assert self._zipfile is not None assert self._mode is not None if virtual_path.startswith("/_rels"): raise OPCError("Writing directly to a relationship file is forbidden.") if virtual_path.startswith(self._metadata_prefix): return BytesIO(json.dumps(self.getMetadata(virtual_path[len(self._metadata_prefix):])).encode("UTF-8")) virtual_path = self._processAliases(virtual_path) if not self._resourceExists(virtual_path) and self._mode == OpenMode.ReadOnly: # In write-only mode, create a new file instead of reading metadata. raise FileNotFoundError(virtual_path) # The zipfile module may only have one write stream open at a time. So when you open a new stream, close the previous one. if self._last_open_stream is not None and self._last_open_path not in self._open_bytes_streams: # Don't close streams that we still need to flush. self._last_open_stream.close() # If we are requesting a stream of an image resized, resize the image and return that. if self._mode == OpenMode.ReadOnly and ".png/" in virtual_path: png_file = virtual_path[:virtual_path.find(".png/") + 4] size_spec = virtual_path[virtual_path.find(".png/") + 5:] if re.match(r"^\s*\d+\s*x\s*\d+\s*$", size_spec): dimensions = [] for dimension in re.finditer(r"\d+", size_spec): dimensions.append(int(dimension.group())) return self._resizeImage(png_file, dimensions[0], dimensions[1]) self._last_open_path = virtual_path try: # If it happens to match some existing PNG file, we have to rescale that file and return the result. self._last_open_stream = self._zipfile.open(virtual_path, self._mode.value) except RuntimeError: # Python 3.5 and before couldn't open resources in the archive in write mode. self._last_open_stream = BytesIO() self._open_bytes_streams[virtual_path] = self._last_open_stream # Save this for flushing later. return self._last_open_stream def toByteArray(self, offset: int = 0, count: int = -1) -> bytes: if not self._stream: raise ValueError("Can't get the bytes from a closed file.") if self._mode == OpenMode.WriteOnly: raise WriteOnlyError() assert self._zipfile is not None assert self._mode is not None self._zipfile.close() # Close the zipfile first so that we won't be messing with the stream without its consent. self._stream.seek(offset) result = self._stream.read(count) self._zipfile = zipfile.ZipFile(self._stream, self._mode.value, compression=zipfile.ZIP_DEFLATED) return result ## Adds a new content type to the archive. # \param extension The file extension of the type def addContentType(self, extension: str, mime_type: str) -> None: if not self._stream: raise ValueError("Can't add a content type to a closed file.") if self._mode == OpenMode.ReadOnly: raise ReadOnlyError() assert self._content_types_element is not None # First check if it already exists. for content_type in self._content_types_element.iterfind("Default"): if "Extension" in content_type.attrib and content_type.attrib["Extension"] == extension: raise OPCError("Content type for extension {extension} already exists.".format(extension=extension)) ET.SubElement(self._content_types_element, "Default", Extension=extension, ContentType=mime_type) ## Adds a relation concerning a file type. # \param virtual_path The target file that the relation is about. # \param relation_type The type of the relation. Any reader of OPC should # be able to understand all types that are added via relations. # \param origin The origin of the relation. If the relation concerns a # specific directory or specific file, then you should point to the # virtual path of that file here. def addRelation(self, virtual_path: str, relation_type: str, origin: str = "") -> None: if not self._stream: raise ValueError("Can't add a relation to a closed file.") if self._mode == OpenMode.ReadOnly: raise ReadOnlyError(virtual_path) virtual_path = self._processAliases(virtual_path) # First check if it already exists. if origin not in self._relations: self._relations[origin] = ET.Element("Relationships", xmlns="http://schemas.openxmlformats.org/package/2006/relationships") else: for relationship in self._relations[origin].iterfind("Relationship"): if "Target" in relationship.attrib and relationship.attrib["Target"] == virtual_path: raise OPCError("Relation for virtual path {target} already exists.".format(target=virtual_path)) # Find a unique name. unique_id = 0 while True: for relationship in self._relations[origin].iterfind("Relationship"): if "Id" in relationship.attrib and relationship.attrib["Id"] == "rel" + str(unique_id): break else: # Unique ID didn't exist yet! It's safe to use break unique_id += 1 unique_name = "rel" + str(unique_id) # Create the element itself. ET.SubElement(self._relations[origin], "Relationship", Target=virtual_path, Type=relation_type, Id=unique_name) ## Figures out if a resource exists in the archive. # # This will not match on metadata, only on normal resources. # \param virtual_path: The path to test for. # \return ``True`` if it exists as a normal resource, or ``False`` if it # doesn't. def _resourceExists(self, virtual_path: str) -> bool: assert self._zipfile is not None for zip_name in self._zipfile.namelist(): zip_virtual_path = self._zipNameToVirtualPath(zip_name) if virtual_path == zip_virtual_path: return True if zip_virtual_path.endswith(".png") and virtual_path.startswith( zip_virtual_path + "/"): # We can rescale PNG images if you want. if re.match(r"^\s*\d+\s*x\s*\d+\s*$", virtual_path[len( zip_virtual_path) + 1:]): # Matches the form "NxM" with optional whitespace. return True return False ## Dereference the aliases for OPC files. # # This also adds a slash in front of every virtual path if it has no slash # yet, to allow referencing virtual paths with or without the initial # slash. def _processAliases(self, virtual_path: str) -> str: if not virtual_path.startswith("/"): virtual_path = "/" + virtual_path # Replace all aliases. for regex, replacement in self._aliases.items(): if regex.startswith("/"): expression = r"^" + regex else: expression = regex virtual_path = re.sub(expression, replacement, virtual_path) return virtual_path ## Convert the resource name inside the zip to a virtual path as this # library specifies it should be. # \param zip_name The name in the zip file according to zipfile module. # \return The virtual path of that resource. def _zipNameToVirtualPath(self, zip_name: str) -> str: if not zip_name.startswith("/"): return "/" + zip_name return zip_name ## Resize an image to the specified dimensions. # # For now you may assume that the input image is PNG formatted. # \param virtual_path The virtual path pointing to an image in the # zipfile. # \param width The desired width of the image. # \param height The desired height of the image. # \return A bytes stream representing a new PNG image with the desired # width and height. def _resizeImage(self, virtual_path: str, width: int, height: int) -> IO[bytes]: input = self.getStream(virtual_path) try: from PyQt5.QtGui import QImage from PyQt5.QtCore import Qt, QBuffer image = QImage() image.loadFromData(input.read()) image = image.scaled(width, height, Qt.IgnoreAspectRatio, Qt.SmoothTransformation) output_buffer = QBuffer() output_buffer.open(QBuffer.ReadWrite) image.save(output_buffer, "PNG") output_buffer.seek(0) # Reset that buffer so that the next guy can request it. return BytesIO(output_buffer.readAll()) except ImportError: # TODO: Try other image loaders. raise # Raise import error again if we find no other image loaders. #### Below follow some methods to read/write components of the archive. #### ## When loading a file, load the relations from the archive. # # If the relations are missing, empty elements are created. def _readRels(self) -> None: assert self._zipfile is not None self._relations[""] = ET.Element("Relationships", xmlns="http://schemas.openxmlformats.org/package/2006/relationships") # There must always be a global relationships document. # Below is some parsing of paths and extensions. # Normally you'd use os.path for this. But this is platform-dependent. # For instance, the path separator in Windows is a backslash, but zipfile still uses a slash on Windows. # So instead we have custom implementations here. Sorry. for virtual_path in self._zipfile.namelist(): virtual_path = self._zipNameToVirtualPath(virtual_path) if not virtual_path.endswith(".rels"): # We only want to read rels files. continue directory = virtual_path[:virtual_path.rfind("/")] # Before the last slash. if directory != "_rels" and not directory.endswith("/_rels"): # Rels files must be in a directory _rels. continue document = ET.fromstring(self._zipfile.open(virtual_path).read()) # Find out what file or directory this relation is about. origin_filename = virtual_path[virtual_path.rfind("/") + 1:-len( ".rels")] # Just the filename (no path) and without .rels extension. origin_directory = directory[ :-len("/_rels")] # The parent path. We already know it's in the _rels directory. origin = (origin_directory + "/" if (origin_directory != "") else "") + origin_filename self._relations[origin] = document ## At the end of writing a file, write the relations to the archive. # # This should be written at the end of writing an archive, when all # relations are known. def _writeRels(self) -> None: assert self._zipfile is not None # Below is some parsing of paths and extensions. # Normally you'd use os.path for this. But this is platform-dependent. # For instance, the path separator in Windows is a backslash, but zipfile still uses a slash on Windows. # So instead we have custom implementations here. Sorry. for origin, element in self._relations.items(): # Find out where to store the rels file. if "/" not in origin: # Is in root. origin_directory = "" origin_filename = origin else: origin_directory = origin[:origin.rfind("/")] origin_filename = origin[origin.rfind("/") + 1:] relations_file = origin_directory + "/_rels/" + origin_filename + ".rels" self._indent(element) self._zipfile.writestr(relations_file, ET.tostring(self._xml_header) + b"\n" + ET.tostring(element)) ## When loading a file, load the content types from the archive. # # If the content types are missing, an empty element is created. def _readContentTypes(self) -> None: assert self._zipfile is not None if self._content_types_file in self._zipfile.namelist(): content_types_element = ET.fromstring(self._zipfile.open(self._content_types_file).read()) if content_types_element: self._content_types_element = content_types_element if not self._content_types_element: self._content_types_element = ET.Element("Types", xmlns="http://schemas.openxmlformats.org/package/2006/content-types") # If there is no type for the .rels file, create it. if self._mode != OpenMode.ReadOnly: for type_element in self._content_types_element.iterfind( "{http://schemas.openxmlformats.org/package/2006/content-types}Default"): if "Extension" in type_element.attrib and type_element.attrib["Extension"] == "rels": break else: ET.SubElement(self._content_types_element, "Default", Extension="rels", ContentType="application/vnd.openxmlformats-package.relationships+xml") ## At the end of writing a file, write the content types to the archive. # # This should be written at the end of writing an archive, when all # content types are known. def _writeContentTypes(self) -> None: assert self._zipfile is not None assert self._content_types_element is not None self._indent(self._content_types_element) self._zipfile.writestr(self._content_types_file, ET.tostring(self._xml_header) + b"\n" + ET.tostring(self._content_types_element)) ## When loading a file, read its metadata from the archive. # # This depends on the relations! Read the relations first! def _readMetadata(self) -> None: assert self._zipfile is not None for origin, relations_element in self._relations.items(): for relationship in relations_element.iterfind( "{http://schemas.openxmlformats.org/package/2006/relationships}Relationship"): if "Target" not in relationship.attrib or "Type" not in relationship.attrib: # These two are required, and we actually need them here. Better ignore this one. continue if relationship.attrib[ "Type"] != self._opc_metadata_relationship_type: # Not interested in this one. It's not metadata that we recognise. continue metadata_file = relationship.attrib["Target"] if metadata_file not in self._zipfile.namelist(): # The metadata file is unknown to us. continue metadata = json.loads(self._zipfile.open(metadata_file).read().decode("utf-8")) if metadata_file == self._global_metadata_file: # Store globals as if coming from root. metadata_file = "" elif metadata_file.endswith( ".json"): # Metadata files should be named .json, meaning that they are metadata about . metadata_file = metadata_file[:-len(".json")] self._readMetadataElement(metadata, metadata_file) if self._mode != OpenMode.WriteOnly and not self.getMetadata("/3D/model.gcode"): try: # Check if the G-code file actually exists in the package. self._zipfile.getinfo("/3D/model.gcode") except KeyError: return gcode_stream = self._zipfile.open("/3D/model.gcode") header_data = GCodeFile.parseHeader(gcode_stream, prefix="/3D/model.gcode/") self._metadata.update(header_data) ## Reads a single node of metadata from a JSON document (recursively). # \param element The node in the JSON document to read. # \param current_path The path towards the current document. def _readMetadataElement(self, element: Dict[str, Any], current_path: str) -> None: for key, value in element.items(): if isinstance(value, dict): # json structures stuff in dicts if it is a subtree. self._readMetadataElement(value, current_path + "/" + key) else: self._metadata[current_path + "/" + key] = value ## At the end of writing a file, write the metadata to the archive. # # This should be written at the end of writing an archive, when all # metadata is known. # # ALWAYS WRITE METADATA BEFORE UPDATING RELS AND CONTENT TYPES. def _writeMetadata(self) -> None: assert self._zipfile is not None keys_left = set( self._metadata.keys()) # The keys that are not associated with a particular file (global metadata). metadata_per_file = {} # type: Dict[str, Dict[str, Any]] for file_name in self._zipfile.namelist(): metadata_per_file[file_name] = {} for metadata_key in self._metadata: if metadata_key.startswith(file_name + "/"): # Strip the prefix: "/a/b/c.stl/print_time" becomes just "print_time" about the file "/a/b/c.stl". metadata_per_file[file_name][metadata_key[len(file_name) + 1:]] = self._metadata[metadata_key] keys_left.remove(metadata_key) # keys_left now contains only global metadata keys. global_metadata = {key: self._metadata[key] for key in keys_left} if len(global_metadata) > 0: self._writeMetadataToFile(global_metadata, self._global_metadata_file) self.addRelation(self._global_metadata_file, self._opc_metadata_relationship_type) for file_name, metadata in metadata_per_file.items(): if len(metadata) > 0: self._writeMetadataToFile(metadata, file_name + ".json") self.addRelation(file_name + ".json", self._opc_metadata_relationship_type) if len(self._metadata) > 0: # If we've written any metadata at all, we must include the content type as well. try: self.addContentType(extension="json", mime_type="text/json") except OPCError: # User may already have defined this content type himself. pass ## Writes one dictionary of metadata to a JSON file. # \param metadata The metadata dictionary to write. # \param file_name The virtual path of the JSON file to write to. def _writeMetadataToFile(self, metadata: Dict[str, Any], file_name: str) -> None: assert self._zipfile is not None # Split the metadata into a hierarchical structure. document = {} # type: Dict[str, Any] for key, value in metadata.items(): key = key.strip("/") # TODO: Should paths ending in a slash give an error? path = key.split("/") current_element = document for element in path: if element not in current_element: current_element[element] = {} current_element = current_element[element] current_element[""] = value # We've created some empty-string keys to allow values to occur next to subelements. # If this empty-string key is the only key inside a node, fold it in to be just the value. for key in metadata: key = key.strip("/") path = key.split("/") current_element = document parent = document for element in path: parent = current_element current_element = current_element[element] if len(current_element) == 1: # The empty string is the only element. assert "" in current_element parent[path[-1]] = current_element[""] # Fold down the singleton dictionary. self._zipfile.writestr(file_name, json.dumps(document, sort_keys=True, indent=4)) ## Helper method to write data directly into an aliased path. def _writeToAlias(self, path_alias: str, package_filename: str, file_data: bytes) -> None: stream = self.getStream("{}/{}".format(path_alias, package_filename)) stream.write(file_data) ## Helper method to ensure a relationship exists. # Creates the relationship if it does not exists, ignores an OPC error if it already does. def _ensureRelationExists(self, virtual_path: str, relation_type: str, origin: str) -> None: try: # We try to add the relation. If this throws an OPCError, we know the relation already exists and ignore it. self.addRelation(virtual_path, relation_type, origin) except OPCError: pass ## Helper function for pretty-printing XML because ETree is stupid. # # Source: https://stackoverflow.com/questions/749796/pretty-printing-xml-in-python def _indent(self, elem: ET.Element, level: int = 0) -> None: i = "\n" + level * " " if len(elem): if not elem.text or not elem.text.strip(): elem.text = i + " " if not elem.tail or not elem.tail.strip(): elem.tail = i for elem in elem: self._indent(elem, level + 1) if not elem.tail or not elem.tail.strip(): elem.tail = i else: if level and (not elem.tail or not elem.tail.strip()): elem.tail = i ## Error to raise that something went wrong with reading/writing a OPC file. class OPCError(Exception): pass # This is just a marker class. libCharon-4.13.0/Charon/filetypes/UltimakerFormatPackage.py000077500000000000000000000041661413347326300236720ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. from collections import OrderedDict from Charon.OpenMode import OpenMode from Charon.filetypes.GCodeFile import GCodeFile from Charon.filetypes.OpenPackagingConvention import OpenPackagingConvention ## A container file type that contains multiple 3D-printing related files that belong together. class UltimakerFormatPackage(OpenPackagingConvention): # Where the global metadata file is. _global_metadata_file = "/Metadata/UFP_Global.json" # Unique identifier of the relationship type that relates UFP metadata to files. _metadata_relationship_type = "http://schemas.ultimaker.org/package/2018/relationships/ufp_metadata" # Where the global metadata file is. global_metadata_file = "/Metadata/UFP_Global.json" # Unique identifier of the relationship type that relates UFP metadata to files. metadata_relationship_type = "http://schemas.ultimaker.org/package/2018/relationships/ufp_metadata" # Virtual path aliases. Keys are regex. Order matters! _aliases = OrderedDict([ (r"^/preview/default", "/Metadata/thumbnail.png"), (r"^/preview", "/Metadata/thumbnail.png"), (r"^/toolpath/default", "/3D/model.gcode"), (r"^/toolpath", "/3D/model.gcode"), ]) mime_type = "application/x-ufp" ## Initialises the fields of this class. def __init__(self): super().__init__() ## When loading a file, read its metadata from the archive. # # This depends on the relations! Read the relations first! def _readMetadata(self) -> None: super()._readMetadata() if self._mode != OpenMode.WriteOnly and not self.getMetadata("/3D/model.gcode"): try: # Check if the G-code file actually exists in the package. self._zipfile.getinfo("/3D/model.gcode") except KeyError: return gcode_stream = self._zipfile.open("/3D/model.gcode") header_data = GCodeFile.parseHeader(gcode_stream, prefix="/3D/model.gcode/") self._metadata.update(header_data) libCharon-4.13.0/Charon/filetypes/__init__.py000066400000000000000000000001461413347326300210360ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. libCharon-4.13.0/LICENSE000066400000000000000000000167231413347326300145240ustar00rootroot00000000000000 GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library.libCharon-4.13.0/README.md000066400000000000000000000005021413347326300147620ustar00rootroot00000000000000# libCharon File metadata and streaming library The Charon library is the responsibility of the Embedded Applications team. Pull requests to MASTER have to be verified by the Embedded Applications team. ## Documentation - [Library](docs/library.md) - [UFP](docs/ultimaker_format_package.md) - [Service](docs/service.md) libCharon-4.13.0/build.sh000077500000000000000000000023731413347326300151510ustar00rootroot00000000000000#!/bin/sh ARCH="armhf" # common directory variables SYSCONFDIR="${SYSCONFDIR:-/etc}" SRC_DIR="$(pwd)" BUILD_DIR_TEMPLATE="_build_${ARCH}" BUILD_DIR="${BUILD_DIR:-${SRC_DIR}/${BUILD_DIR_TEMPLATE}}" # Debian package information PACKAGE_NAME="${PACKAGE_NAME:-libCharon}" RELEASE_VERSION="${RELEASE_VERSION:-999.999.999}" build() { mkdir -p "${BUILD_DIR}" cd "${BUILD_DIR}" || return echo "Building with cmake" cmake \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_PREFIX_PATH="${CURA_BUILD_ENV_PATH}" \ -DCPACK_PACKAGE_VERSION="${RELEASE_VERSION}" \ .. } create_debian_package() { make package } cleanup() { rm -rf "${BUILD_DIR:?}" } usage() { echo "Usage: ${0} [OPTIONS]" echo " -c Explicitly cleanup the build directory" echo " -h Print this usage" echo "NOTE: This script requires root permissions to run." } while getopts ":hc" options; do case "${options}" in c) cleanup exit 0 ;; h) usage exit 0 ;; :) echo "Option -${OPTARG} requires an argument." exit 1 ;; ?) echo "Invalid option: -${OPTARG}" exit 1 ;; esac done shift "$((OPTIND - 1))" cleanup build create_debian_package libCharon-4.13.0/build_for_ultimaker.sh000077500000000000000000000042061413347326300200710ustar00rootroot00000000000000#!/bin/sh # # Copyright (C) 2019 Ultimaker B.V. # # SPDX-License-Identifier: LGPL-3.0+ set -eu ARCH="armhf" SRC_DIR="$(pwd)" RELEASE_VERSION="${RELEASE_VERSION:-999.999.999}" DOCKER_WORK_DIR="/build" BUILD_DIR_TEMPLATE="_build_${ARCH}" BUILD_DIR="${BUILD_DIR:-${SRC_DIR}/${BUILD_DIR_TEMPLATE}}" run_env_check="yes" run_linters="yes" run_tests="yes" # Run the make_docker.sh script here, within the context of the build_for_ultimaker.sh script . ./make_docker.sh env_check() { run_in_docker "./docker_env/buildenv_check.sh" } run_build() { run_in_docker "./build.sh" "${@}" } deliver_pkg() { run_in_docker chown -R "$(id -u):$(id -g)" "${DOCKER_WORK_DIR}" cp "${BUILD_DIR}/"*".deb" "./" } run_tests() { echo "Testing!" # These tests should never fail! See .gitlab-ci.yml ./run_style_analysis.sh || echo "Code Style Analaysis Failed!" ./run_mypy.sh || echo "MYPY Analysis Failed!" ./run_pytest.sh || echo "PyTest failed!" } run_linters() { run_shellcheck } run_shellcheck() { docker run \ --rm \ -v "$(pwd):${DOCKER_WORK_DIR}" \ -w "${DOCKER_WORK_DIR}" \ "registry.hub.docker.com/koalaman/shellcheck-alpine:stable" \ "./run_shellcheck.sh" } usage() { echo "Usage: ${0} [OPTIONS]" echo " -c Skip build environment checks" echo " -h Print usage" echo " -l Skip code linting" echo " -t Skip tests" } while getopts ":chlt" options; do case "${options}" in c) run_env_check="no" ;; h) usage exit 0 ;; l) run_linters="no" ;; t) run_tests="no" ;; :) echo "Option -${OPTARG} requires an argument." exit 1 ;; ?) echo "Invalid option: -${OPTARG}" exit 1 ;; esac done shift "$((OPTIND - 1))" if ! command -V docker; then echo "Docker not found, docker-less builds are not supported." exit 1 fi if [ "${run_env_check}" = "yes" ]; then env_check fi if [ "${run_linters}" = "yes" ]; then run_linters fi run_build "${@}" if [ "${run_tests}" = "yes" ]; then run_tests fi deliver_pkg exit 0 libCharon-4.13.0/charon_requirements.txt000066400000000000000000000001021413347326300203150ustar00rootroot00000000000000typing pycodestyle pytest pytest-cov coverage lizard vulture mypy libCharon-4.13.0/ci/000077500000000000000000000000001413347326300141015ustar00rootroot00000000000000libCharon-4.13.0/ci/complexity_analysis.sh000077500000000000000000000002531413347326300205400ustar00rootroot00000000000000#!/bin/sh set -eu # TODO set limits, shouldn't expect 100% lizard -Eduplicate Charon -T cyclomatic_complexity=20 #This value shall not increase, target is <= 10 exit 0 libCharon-4.13.0/ci/dead_code_analysis.sh000077500000000000000000000001021413347326300202230ustar00rootroot00000000000000#!/bin/sh set -eu vulture --min-confidence 100 "Charon" exit 0 libCharon-4.13.0/ci/mypy.sh000077500000000000000000000005351413347326300154410ustar00rootroot00000000000000#!/bin/sh set -eu MYPY_FILES=$(git diff --name-only --diff-filter=d origin/master | grep -i .py$ | cat) if [ -n "${MYPY_FILES}" ] ; then echo "Testing ${MYPY_FILES}" for file in ${MYPY_FILES} ; do echo "Mypying ${file}" mypy --config-file=mypy.ini --follow-imports=skip --cache-dir=/dev/null "${file}" done fi exit 0 libCharon-4.13.0/ci/pytest.sh000077500000000000000000000003601413347326300157670ustar00rootroot00000000000000#!/bin/sh set -eu py3clean . rm -rf ./.cache rm -rf ./.pytest_cache rm -rf ./.mypy_cache rm -rf ./cov_report pytest --cov=. --cov-report=html --cov-config=./coverage.ini py3clean . echo "Getting coverage report" coverage report exit 0 libCharon-4.13.0/ci/style_analysis.sh000077500000000000000000000001411413347326300174770ustar00rootroot00000000000000#!/bin/sh set -eu git diff origin/master | pycodestyle --config=pycodestyle.ini --diff exit 0 libCharon-4.13.0/coverage.ini000066400000000000000000000001071413347326300160000ustar00rootroot00000000000000[run] branch = True include = ./Charon [html] directory = ./cov_report libCharon-4.13.0/docker_env/000077500000000000000000000000001413347326300156255ustar00rootroot00000000000000libCharon-4.13.0/docker_env/Dockerfile000066400000000000000000000004351413347326300176210ustar00rootroot00000000000000FROM registry.hub.docker.com/library/debian:buster-slim RUN apt-get update && apt-get -y install cmake make python3 python3-pip git COPY docker_env/buildenv_check.sh buildenv_check.sh COPY charon_requirements.txt charon_requirements.txt RUN pip3 install -r charon_requirements.txt libCharon-4.13.0/docker_env/buildenv_check.sh000077500000000000000000000012031413347326300211250ustar00rootroot00000000000000#!/bin/sh set -eu CROSS_COMPILE="${CROSS_COMPILE:-""}" COMMANDS=" \ cmake \ make \ python3 \ pip3 \ git \ " result=0 echo_line(){ echo "--------------------------------------------------------------------------------" } check_command_installation() { for pkg in ${COMMANDS}; do PATH="${PATH}:/sbin:/usr/sbin:/usr/local/sbin" command -V "${pkg}" || result=1 done } echo_line echo "Verifying build environment commands:" check_command_installation echo_line if [ "${result}" -ne 0 ]; then echo "ERROR: Missing preconditions, cannot continue." exit 1 fi echo_line echo "Build environment OK" echo_line exit 0 libCharon-4.13.0/docs/000077500000000000000000000000001413347326300144365ustar00rootroot00000000000000libCharon-4.13.0/docs/class_diagram.plantuml000066400000000000000000000036061413347326300210120ustar00rootroot00000000000000@startuml package Service { class FileService << DBusService >> { +startRequest(file: String, paths: List[str]): int +cancelRequest(requestId: int) --- +signal requestData(requestId: int, data: Dict[str, Any]) +signal requestFinished(requestId: int) +signal requestError(requestId: int, error: str) } class Queue { +enqueue(job: Job) +dequeue(job: Job) +takeNext() : Job } class Worker << Thread >> { +run() } class Job { +execute() +requestId: int } FileService *-- Queue Queue o-- Job Worker --> Queue : Take Worker --> Job : Execute } package Library { interface FileInterface { +open(path : str, mode : OpenMode = ReadOnly) +openStream(stream: BufferedIOBase, mimetype: String) +close() +flush() +getData(virtual_path: String) : Dict[str, Any] +setData(data: Dict[str, Any]) +getStream(virtual_path : String) : BufferedIOBase +listPaths() : List[str] +toByteArray(offset: int = 0, count: int = -1) : Bytes } class VirtualFile << ContextManager >> { -implementation : FileImplementation } abstract class FileImplementation { } FileInterface <|-- VirtualFile FileInterface <|-- FileImplementation VirtualFile *-- FileImplementation note on link VirtualFile creates a FileImplementation based on the mimetype of the file it should open. end note FileImplementation <|-- GCodeFile FileImplementation <|-- ContainerFile } class FileRequest { +state: RequestState +file_path: str +virtual_paths: List[str] +result: Dict[str, Any] +start() +waitUntilFinished() --- signal dataReceived(request: FileRequest, data: Dict[str, any]) signal finished(request: FileRequest) } @enduml libCharon-4.13.0/docs/class_diagram.png000066400000000000000000002232771413347326300177520ustar00rootroot00000000000000PNG  IHDR v3S)tEXtcopyleftGenerated by http://plantuml.com09zTXtplantumlxUmoFn)a>;BW+.@K Cu:-1;v@EH3;ad;6,zaK9-9>Qʴkbuq~aEj?s36~Bf!̍bم 3+„k6k'.c1aZ6:^n@,R{ kYv!&acqp)v_;G]\s ZWJI&(Zia\ 3lw+;^ pH6qL&W2 |ˍKwAPd^]e$Αl\!! -Z111im۶kX,M63gƍ .Aǎi3 @2AAT&''d+V*U"$h޼9kΜ9q A   *fH&rw9;;K&&jii۷'ڪ\ &jŊ]5Ad  P Y>䧀rww0446l.ɤ;w֭[WWWiӦubLj֬KyIsrr_ƻvBkAAPABLJF޽L@M ,X!  ɏ?nӦ ʕ+x{{S>}5Ad  Bɓ Ҟo޼KΆ GkAAPdUF :e˖5Ad  C)SD ! 'N`{ݻ7>>>##۸!t ݺu WR wwwbݻ/)100sССC<<<-Z AP!NE)Њ+p5!*"<A+?^H6Y!s„ t ;wՄ $@ӤIDs$[ R%巗/_Y5A!}Ç>} 5V߿?ŴiN>s L4Ae«ƏO~P9.@2AhƆ|"""(f͚%%%'_|@j׏O0,'VM9ɓɹmw!*ao߆4mHWWRJM4IIIdH~ںu+qrFFƍ7r C25- ðN][55xx䜜NH Ɩ,YBkCݳg233'O'Ɛ0a;ve ,Ca&HvvvdJskʊI]HH^5k,2L{Zׯ?i$ZӴiS.#ի"ɔyhhl```aa1w/^)SXXeժU+y @Y0 {'$ٳ'**ɓ? 3115UV#|-?ĉ>x.\C2i۶m/_5l7|C7o>1$O<>={'USwRʜv{i  HaXoZ$CTZc _R%bGѿ]V\9Zycǎq|Hn۶- 6 .e"XZPŸ)d.M67WSNڵ3f@kɀdՁ(ߠ\ U𐌡a.qo=x`Ȑ!˗r>}_R֭߅_>-I$sgee7WSN!!!Cq A Qs&1}cj /jHk~9V*xHЂ0 8m=zjL]mmmJ@_~o>/_Mr_UKK'''222 n)?Lv؁ɚ[G2w&q~JRPVyIˣ*;$Nq ..H^{\ UXa.q9s&%055eWՇdR>}h9mذdz.sAwk;y @2 xi>Q%LL[7ƅ4>g-J .H>$ 9W[X "P֘ڵk/^/EiӦ~'v/^|YVV͛!e_]Ν{1ռ *wײe(e׮] @2K'ĴYB6~ѨcC`@2. E71"ҦMV\YGGGWWۛ UMzmpppf(Mǎ7n(C2mnjjJ_ ݻw֭kѢJy|QPq A Mr$R"n$'3Pt0 a!,X1` &h=1NĽ| ]<e]&xA>Ϧ1vb*^M5;rQ7Q5 \z(;>gc=$Z7//_a\!,;l 7klll=l7mZпO?MlaQ-5u x.F"|hwzss`dd?S[[mG{2:jժÇirG'~;=%Ku.Cd ׄ  -b4gHNtD]o$J=qˊi6onsɓcO;WzE6jUS!ݾ}`>4ԛyz5Ҷ?~|fE !r9Àd}nWT7bU3c[ 5\'N`SIݻ7>>>###?mCamE0dW ;``ole׮bbi}fII;ahwq4zFl={:ɖ8H&g͆̇v|t6{Ѣ sݿ0;;B0Зd0 .N;v_^u ~2\j 8"ݻw_R㣯OաCCyxx,Z HÔH.erVI5$o:B/_/_^;+k`ɵkI]$qQ+E[[T\ٶ_}6PddlС ac#i3ϛ駣2AxV0ؙxQ&l+rpqvNh,`=V0gݬK };.`eUCs  $ *&a\#;$e%uV/ETLPJɄfo~in[==]O?%ysɩi޾}̇vgbbRO,@}:u,Ξ9Q͚fgL: 2$oj>HLȟ?ttuxYV9/M=~> 96Vdn۪UM ],XZVƗHr) L4Nq*4S7J@rsr~! ɉStR lӪ[rBBHǎ-h!..SrJP_ȼtԩCii֭M۵kP!4-qf|q63Beܻw_[[2$f&OOizDʣG7 SN;3z~G}bV/<`vCcC1K*!9<|E"ܶrC|2 d۷>|S6Y ;slj; q|L!ӇGZt)C&[v:u, ukm;~3 7PÇi9(=SS|1߽_Mcjol͚giVUY==G$sɸ0cb;gog+rrhiqydq$C{SQyP3mmm:H嘶mV`i̘~}kkkݹʖgpxL,lfVVRuE=$CAǏɡjPMVH.hqb]߾UAQ0 .4,ι]o=}Dwņq?${wC3f SWY[NN^#76m؇ysA+OGG!0VdLb_U^M`>0$p'%ߑ#GR5M59\:'Gm3ebxPm^\1-N2-KBrNSSnȄPo7mukE&L||xժ~(Cd&.SǂKրd(Ş 0 n?8uQ<)Y~LWy d _mq9$&ag̑1aѡQgD"Z|uD:R ^5|sƍC}m,WY~H^M r=--S>AMNe$HUs5$afq$òqƸZ}c:]a8:FUg>>d㌁a@2H~ů/Y>_LQ^ÖC^^cٿvmŭas,X0s[/G ߾?&ץbEu-w0`.Z5Kr7/lܸcOҚm|zj6`iuf3/_!D$ ؝ܿUVLLhÇir¥a$Jf1#cއ:'5p~< d= 6&{߽8i͘1fv姙0aȑ_Ӧ /2w^VF{NG]ĉk rN-Wヒmi-yskZO;vlɓJz~*>֬ivTK#0SfI ի3[.JH6)i>ѣ4]'v 60 !k0 ɀ^vcc#MD8X"GEy_fٳlĢ_wO-s;exgln^?==G?;ahO Ҝ;B׮/mkoqK?^˗YOO"-%nݺqr5d 0Zd $=qu{$'{ ijjE)r7nZh!Xi"嵿*߾Ho.XZV'fb .jaQMl~cDvv5]b>']p<-G_Umm-RrhН֛;sHޱ×4׮%Ql%:/I{ 0Zd t>d1D@ȟ%O::4Q9A=I 5_|OOO?tpU b`0LNOɭ͚g+>M711JK￟n۶ڵs{wt}:y_`@2 hU"$chAxB@2 ΑC'=Ȑno~c<JeKy^˧_a~6֭+qgVT!#c{٘UZJnÄtTv֏甐ٜk9߿)ƍ;ī3gvxySҶ;*QE5: Ks.ev􈈕Gn˖M .\ Ha\ 0\ɥm &-X">Xxns/'Gena,g MF-Nu-ׯprꦯۦMP5XzΝ[[[&$f(ЉJ{?}zߵk֩caddЭ[kKN2e؈uڷoƭYzr办p8[ 0 8"Z H.>)&6Lvν׋IT+:4#\ a<0 7?@2 ;ڨxȮ]нm^\tPa PpYda̓ 0  H.o+!xDL(FLDљa̓ 0  H.1 ;G1x'aNdɀd@ripr~h*CkAZ%ɛCRYY`3'è0 0 !\ ݙ=VNufU̪. [/3֓期瓟ǟ!1ʐ[&&FuZ.X0ٳ`r@2 a@r~I *U*׊XYėشFw"-m{a8 !@r ]\KT\N_utu(;>"j*-]:37oYjpAe prL7*wWK坯"hӦɣGi_E$c xn71 psrRҺ=l?>}~ܢE+HQZ/Ffe*Ezҽhdd@TLI%NMBOk ,yfZv׬;q$cFM.K4|Z.˄Σ}8Bǖ MPD7oքxlٰ@#"V:8t0Cf8-:c($6e uwvuo$$7;'HL }/**|V\x]E---:n{ !_qBRr墣װ=7`@WT%7Wy^˗c– ؽ]/^d0 UV߇$,~yaSՊ'gFw}w/$$'=HTe;;[AKښ[_DĵqY1ec 8t\ܼ9}Eάz$'߹Z1AӧB1$<=Gc6ܿR.8A)}|&UˌBVrTB$U*#O8گ7,LulYDT>c+4R#:&8䯹t *9,?W)܈R=Cu=/؇yI6e 4m$^^c^ML\ H.0H*x1d?|8$ċ^”/;gjذεkIn=۲MtKu&?W6=7~DkcjjV2dcP8/HX;q$XQK'<%K ee9unLm8}iA.1#/wQ~j~mH:AH ;`v!ӐccznϖqSZw $:'ha=D ɂTB$U纨PUB2PzΎ[d9AƍM֠ +?;FTLnnvZJ5* C0]c >['|)RVjJztn4=2H-9Vf֬QWyfCC}ֿN 7VGgddP!߾}ӧ~}BQ>̟B1a|bZ$6kܣ:͞z z*-ٰvhǬxv-ζ_#I&C2z:_II.㐬k8u)ɳRX8d!  i[ RV+Gh|ՙKvKl9#c J$9sjέ[G5kwaLlbbddZ `+Wduw,=qpL5mǎ-܃wbft.HH|;qWs¬\acʕ+WF+VLg+<*V4Og-]Fxy.męR?pCq&/!DF j^]͕O[7 ֵ-a@2 Y|>|H.~g\=>rRuN1Α =4/WL?ޙ0u>?Wz<{N*\XYN_իgK>ʕ+.\7dĨ(O§t ݚU w>~|3+D7{Je $?)(H,4R~":Æԩ%UT)󨨀: ݭ mBzʥmQdB-ȅC'S02ҟx:r\HV6\zʐcoӦBԷ*K<ɀdFcTBr͚f.DR澾SOýWRQ''%%eddܸq p)dӁkk[;2RXN030^@'s*Xiӄu.Y"-~qNN޼0 HaBr;)ÑNۏ|+?#zD*_^T--G`>7/,ʢ$0R9Orb\R|z RzjUVlUa$Kv>r<Sv'8gMٗh/sujKNzܑO [ײJY$0 HaS䌌=pߢ`YA0V߸Ur:)cժ^߭YqׯOs挩TE-\Wvnn ֬Mk6o^ظq]ccCGlRM'YDVڛUo^Y̬u$0 HaʒԎܵk TV0 DH&B>41k(IhO9z9/]}7y>ȒMim~~J@2 3'~mt * ؗ ;PdvvDͣpR"UrO>+?_.XH~<=X-,4kVy;v>ny8 ,_!9jd֎t̼):_nٝעEHٳGeݤGmP8MOs*uXpUa;SgAAsjԨJ n++1jmbeUC2OKޕ0 k2$;>63VMB ?LO7Ȑ^o;W;8]dljj±}~t8!c2gmժ꜐H6mp9s$ڒCz& ܓ[_Đ p6 YׯQ%EfffюE nn3k$3"yHuU$i #GڷoҥOGEs9&&0.hnO*\2 9p}soINnݺ1w ѣͭ/^$FK*70,7cF=U237%e}Zf #)i]QJ{{}y`D@9E1oOmL+Y7kpAիNۏBs'ֆ{׭׹sK?&pmŴ@g  Et:u,ΞE5 95k9s:qQ jլ޽۟;O~~N:v 6 åWCh>$8MIiԸƆyɰ8ܒESVH,-]:'On>}=Yllq֮mz06S 71Gn2g{ӴIKAMN *Er>avHtuuuC jٲaKՏY(L)/m_\aK^<3G.a#jdk|]!&9NLG+HNHL͸-.pLL\K OҢw$[.o`_rw-st- +!s`(H%%< 1!0Uttux% |S>L3w>dùToߌHLhj[pl+U@ EKNJLNZc۟jc6ѳHsww':+Wu30޴i`Nf py'9p5C53y~=Srd?+V4v?5]r8ͅLKN#+!A6\d2$>ݲe-ӑӏ\reN:ݭICf+%!Y@ܲUP>tSS>{6|H&;{vWÆu]KBP%I622POӐ֫WUrY0:#z~G}lV/<`vCcC1KO{yJ󢚍Ŋ.:eL'=^8MllP_egqE.l#n|9&,l YQ."dݍ8LO~lCr;x t&dfy@gn!Y*(KLL 59p-$[pe~OͲ#BJo7v-}bVׯs{ %ψ*Usr&j=l%w6xn]>ZՔʿ8~|3#5uM=Z#; $+B Ce:$ ÚɱĆ w4 _~z.E Haۚ ھSr<+eesLJHfʖdui[ȊdrnÛ7XZVr1pu;{ *!ݧO6ȥ 72$s$(Cի :nZ-n?l>5˖ )˿#C!S/yFW):z$ؗ֡Yor'N~*swNU𔫑cfqS)efVʪѣiFxyn_??nMM (qrjԨbt`|H>hI2ܼ aOp2$s( E tCeSI% G%۶$;|6tJ*WdZؿUt|}T\ϟI7u4qd@2 U 16;NԆC{7r$$v ~zġ5׊XY??Žr"`!l~Gg5HU\cyStS; OdrHZAC}wvOiUriڴi:_>XWW-b8Oa*9gx'eHVgĺy\]$+OY73 ; nӃCKKWv^ei?xpOٳ?&jSF.jO.0pb2NZ4 tw䙟}WV`L_#W"o`8aLIYTN)7u'7 =*U^z\r7Wyܘ:?őd I5H| 0=^ e =eʱ ]]s,T>%K&;;ͭ^M >w9s0֥+VLJ!49'mu%ss Pc!ߓp7r,9ʩ;j_\Υ)SyHIS*@K57;EW$_7:S StɂJɥ7K$|n<<ܺoM[c"vv !ЪUӧw0= Y2$b0 HfazT ,'K836SN߽|R'NloQg/^2}Z姟Y?9eFXLΘouĥ/;\© o*=2ptrZARD*O;w]@M}4%orbRn SeH~yR*)TSnnUa,:2Q{r{cv Y3?E:wKX0U) ꖞ 7CԶmS2.BؐBY۠0{n#y0 H&9aGdg={Q {UO$M\v0 q} wthp}ִ* L_I3 ִlِ>֬= d~@r?֋#%# ![P7d==#b Se5ϯ)j?/¬%hz(=CUNYḿd[\63y~]*(|NM2bc͚dw|UҮ]S~|  x`o0 !H6гWV:W|jUM,[6M%$;)Ru*Àdzs!}mA]a8: %=WͫW̅fNGr` ըQy>|FCC "%!>޾}ں5Y}fg޻wwaaK6l.Y\\ռ% gP0f(qhh}Lo8Y||0#ng_::婭('9̞Up]Qs=?YKKr$&K]e %읡Γj ;wrǭCGZ͛}qfllذa}$Ǜ7ԫW3$ߣ[יL'2 a@ N#pV5Cɨ5MИ@1$ 'nr7$fmb鞽ܝf<\Yu×$ n2B\UM#c:Oc\w}5.Ϻ[ O`.C8zWM~ ]`.ː̺կ_+"bec'7Θb`YdcR@rV5 j8͙3;۴i#7p7pd?>a{aYy॑q'0 E H&$x2^,\ʟ3BcR@raW56 ko:X4c{a@2. Àd@rxR3*lll޽\W95jT%bJHn+9AJ*IO%7xs1u~},%FZChфb9X$Rg#1 À Àd@2罗Zmv7s4'ea~AٮIL$JIY_䶒!͛ FFvJn;wRSS'թթ€͛!Y8IHVfɒꞽ7 {1q:u޼:%/>#Gڏowkml$+% /_\"}㳳#:vlqFgx'K $C6YE܋eRMq00 !9=V;scV(_֍dIH6('=Fqa@(w7ϭV+Wį] q/>)C2U?Lu^NAwkml%{KH>p`5A/k)@2 ԲeÓ'a A2 À®jwkdLjdn~bH(0 H@Ws¬KvSS]qIa.6T !Jb-.ov No۶޾ƍ][גPYwmڴ @2 r$a.,#pV5CԄdn-4AwkÀdnu ׻nuJTx˛_ef<99)i]Q??Gwٮܷ{Br|;;ĵx0 xMJ$F?Ct[XeMU VV5F<[]6M|DD'025݉ġub?\gҩXbY[DnC.7AJj,*:u,2 SFUBSn+J~g32ܸsx/O.H&yH$&m6ץc_?wS9HV[hpDE𯎠gzx[y]?$H4lL:Ez |]]ӇM]W>$/^yNnGҚ7x1'+k/! k*[," (~mן>LKnݼq7\OKB(C2Q:TROsRDSRתepRdggKt>J?G "y$l|N _T$ NM.D6lX88t_A Wnv`T^^cI.k8u)? .NF5BH׀]ի.s~:{v%׮%DŽ=Jcn ݭa\!c% ɐ ]75 iAr)\Si>$G~ʪ3c]L$_ trƖ٭B =pco޼adhG9#BVN!E($K2$s=g<)ZeH_V._ [2p`v횲 79]˗ dwYeOpjr1c MWV֭ L]KJsz d@!<9HirD\)e7+gƦԿrsr̥g޽4<w0 YzQoHHZ$4d d*jz($ٹNg('iӂU+uj(~T`)_n]ʳvT4׍wf$N!NFLYEݻ'dWHqB;*c?=]\(zjJ~#$Ԅe۝L$ :3JY`f\w봴fb ]UMo_v p~)@/'\hGe࡝iͲeTB2UDӅ/NǏofݭUT 9=}'+qvah2$K^<$໼o"SXM}~ZܶmӖ-ғnԚZACj- m4' ʕU% YJsJHh~Q{Us X\' M~++Ӓ;`@W~%U+rj{QWV%$.Yh*T ֍3jDL=M\_Uy_)+saC2իW4{# ~<~3݄ !Ht,bYQ9pݐVV5X':;M kܸuk* rO.H+D|5>sf'1?`~ȟXm <7>җn]KBeHޱ÷irNUYIaYQB j'ɾmr0 +A YPAjU+rŞ~%{ZJB= QS^uԐS$PՇ$fmjulp&-' wt\\B >W3kd*3}8=MfS#m@2 a@r~!‘۰(ZmrQ +A <_Cx9VssϐdOK.}W|uTWx]757Hݚg0䮛j_XOESeMHaq, TΝTF~f8J~V ?mS¸h ǭ:+$֢.&,Y$SLH.]4E5BJ0"B$YW0G1FZObg  ðV&cU\\츞9GDԇdپ^B>;y;tuufKq!%@rNaj 1Kr͛N6Ο#KSݲe-UƆ;ׅ޽*ɴ4FDl,{d$Yq}*k(#JceU; qgw7iѡC^ km]XFąa@2 0 L6fۏ?&/Z4@ҥyY~Dh YCky;/gtmPܻ)%7gojCçV_=H?ذaZXrF Xs5=5ݭig"+%e}Zfn;r|HGǏ"ɂ5on}b$UYY{--G7')[61q-~ vJ j,R!=3K=$\>ڑNq)-p B9;IvJ~ɐI޲UpN๺1=}g۶Muـs.o-;.QQLgI{'謹!ک@r͠CpzJj76-m{^~WO-^<ZZZt (<}{w}wkW:Iwqu>}}ù/8}BB`8A $ŐLN(FuH>8X6M^r!Vs Y{SЄbQ*GR 1߆S+t|#>{VӐ`e"jЎ8OoILHS$KGEW{\ɢek:)ę>L37k8\2 Y~qRXn<\ilz6-7ΉG_kגFl)|ymhlll֭::y/3B87?|8$ċp^;,Nܽ{aU. X1Y>!F$Y3!Y(~Y!&t&>88!P38S2Pr$sFjdZ28$d.|3n^eUVrq&VAڵ$~=ERBT"ɁCFEyr5ع}gʪɃiy$Z XTn㚖knkRKSw˅\PSrE0 b(be!*aᎊz_?w90 37/tLF7ʕmt%Y|WTZ8thQzE2n}JDZB3JBkp*믱7m/7(;E[ Bx!YA$񭻿Ivx/DB:Rc撠jvsyvcJrtZ`_=Rm HݽSሊ@BW,59sNj y\LX%Z\֓|X?) #3XL]0cѺ¡x1CV?}wڥj+_:EIfdi/nA~/{u/ifɘ?V$R]Ν[8aР$sI I^ztUo O;a3&F]/O#}*gf<9G|17o4cۺtdiF;#zo!CI[Pi\(QB'% J|yq4gdM###[q{Nwvg=.2 򐞞nIIvKC|8%{dtXדGI-h۝*>pD #j%@gPAŗhsaXCZϱ1%Y$̴cInzuwW7+3s_s8ʗ/viG~ K&ެ4w^kΝK5I'Ҽ8%c$YtOܤI=]h۷jE+(\R"lfv߬i;ޯ|Ny>9SC@Et\\\^f~<*%<<\GEP%7埙e7Qcy̴$_͹*ޏ;Rqԁ ]9`pDŁygKyX3G(ڠ Kι0$-jcPd02ӎ%gsU:p!!+$Y;epV x9BS%ix,٠XMs. +IKA$Y$̴cIuV)ڜ׌KdWDs7XJ-ķOd`;@W%I$AsV Iox؂#Ƅs߿˂8jq""o߶$=8$=Mq Q|í6 :uI68$4sD͚UBq'٠XMs. +IڻO5Ld0$Y gwt)dɒ=zt*,]I&hd |J0l?Zqyɯ6{(n.2 CKJCK\CwMb\dٵ˧aCgg zVVEM j&&!eb-_ U/78.:°{.02I&^%L\Ĉ#-zLqϿ <@|UqVœ9q-Ulb. O #3d U"A ɷ^pvsv%ݥKݻx1 I -HlnxK7o7'_c5ڹ$A$d9sfb+A EhNyF*ܹu\& /,ao"ɚooI65uf͞=}ժU7nZ1dHm>6$w9slݺ5ʼrn>vIJ2A\I I~^ݟ2d dKK˗ݷoIII},)c*3TmN-&M'/oѢl.Iּ"~paeeZf6F^pg$pl'l|Sz޿ÂDy EO unތ{啦))N!DE*]&yy7[v˖M$*^/5]Z6(XGдic.XX(\9z%Jˤ Aqo>hũ~I~v¿yժmD+3MNYɺCm{ 6ET| <(͡OT(Oɽgu8Y1nA+ eGn`t7bN$#G{ɒ,- $>SGK'䯿֨i|qn NH*Vpa89BrII+hIŷe_յW_m#QmNVe_F $LT"VkҤA@kC3c8Cad43gCtKN0x %]_S+(bI.`9I+lH9rBc uw_1r:mr#28YkӰUg#=[Li4ѕdk8FHGlM3f(c>rv"75O|^0c6Ϡ@cٵ˧aCgpgjB'K .,ڵhѢqrM6\f ϱnɓS}͘E+8~ {1(ɚ'~!wVwsH;6EIV!D\$}Zk\b=^BJ툴\7jv<O%Ü>:ujFLدӳۊŋ?Գ3H=\I6F% up(c2fhiU73ĤϮh3o=,%Ũs'3s !~*1cHMwHrA>V$Yk K.P#2i`)S{D$Y5|pkk8ƂHn7Wm#5KnBt7bN$٤.o4X5L9/~6&#u+aΜ}DdPa+[Oh~yy\\^Ύ&>dQ+n_Rjei8}M [۷oU؎I6Y[ f'֤ꡩ}x=3@3uZ{]#Kr>nS Ze˦Lw$h$wI4+H?yy}ŗGx_y+ QZ'i'70k̘֒iIZ|yO3#o8W:SNJr>n*qܖJo^IVI6K9lB?>Ί71_9,Zg .Q#kP& 61u/W<"QZ%bc/rRbZKi9,wJ{Wt`P|Vq &Ak#jc;d;Hߞ_kݶ0~ B d Q6|4+ȝ;B}MDߘ۟}֝{ì3[kIZ|yO3#s4%YS| Th~[ǎmۢOS5{ҥhEIVI6K9rP{Kj1/oz޿c䘷0e܄ErqyI6=I&I=ɖL^ NCd)߿~-WiϞUZ$d&MnU7[ɡ432{FFH=4KdSo *4/$'t uU'Y;$Aj(&lƏL$A0qe(S4zV-e˖MQIsRynYw~ -z+"94f'y&/_655Hk)^wykb,P55eڴy/%Y;$Aqy ddcHȣJ.լswjdFJf}=;gbh $(Ie8֭+h2ii1j5=%2]idfU7d.Ž5)zhj4_N@lVjw-{9q)ZeV7axo ss]Ӗ~A}$_9($ "~6\5I5J.Tĉόs[B]uTJBzB.L df]3$Y‹KK,٣GkX,$AI ?.a+$Fv[$w^>So{@5O֒d!JK4΅nU7ҚJIS[?@ $W d,%.aa͒|]S>I6XB+Is[Bt%Y$ XI&d+dŻG7'_cUE׼ీ|4gHz|GR8)xKg-2au1uQP^Ae:9t=ψ0%[$Z{~"YFϮbw \FZíKBc.=GTH2$A`2۶my#=[@I֜FSId.FӀ%ExhH JTbbbddd``v>v5#ܫA;3sW.>%'`*-⛞n=dͨ^${}#%YE.j΅ /HZI|H#*`] $@JrNU-P5$fIVР$K9:8)=Hrqd**>pѓfwUrr|=skqM̴I^Å'ϏY4tהZN 4F ֆGsUQBd7,bVBu 7 RI&d%Yq>ڽS=)rnnmuf+(nSm?֮] ʽsBCd&4r#EmjjII&nj6#$#2SJ|3 !l*]vDf$Z|w\|ӳjvu'64Qja=o+ҍ{zJBXI] $@#ɊIyɩ_޺0yHMI֚Om@ m۶$kJ6͛q4%%Pۉ;޻xZĘ5݂oD$_npBc[K횖$+t+#Exh&H PTff4SSSհRyZ 3`{-;.o߶:w] c X?-x0MZ"ISS-$Iݦt׏XE8_:]I'4~#&r3iڵ8̐E{h&H Pm5D9;;[t*Hr͞dLB|ŷ[|7ÇH2AHJw~<_Ij$ɺ$Owp.]\KQu[NNd/78a%Y>z~-/C3iGI.l%:ɌJЕr(H2AHQ-xí6 S:uIV-\kM˗4EÉ!hלPsS&mfͪb;ǼI&r3[Nw]lJQ=Hr[G9(NYd1 g=MfI_TfYIIVO]|6tvvpgjU$Ym@H*/BrB=wiI5 \& !nr3d.Ž|aZZxzzL|$bUMIw谯ٰ9$UMLtYǍΎsp(3uI5XTfc3R_-Ř-ڵk)ؾ}+:2zZ{?uR~z#fhnJ3g|>wԑ0YlGIIrP`U⏯^N=3dC~e1իg ٹ~!_,GY35S IBN̛7ſ$kJuR~z#j|tٰ{/F! H2$#ɅwWRݶz[9~٫Vɨ+tjYDFRe˦L<_j׮~^kdhݺ7lV4Kj=-ZfTNJQujzJ4)\/GڸW3tהűxc}䰰UoQZQr2Z6Xݻ_KؼmۢOS5{ҥhԜ4iҥn5kV8LtSŒ;)j(ɺ7TK-Z!BO4iG}.j.Q̉I3G,|}gdꤚV$#A +ɔ-%y, `\ja1dI=ArЕ%Yz$=NNܹ ꪻ5"ǫUsj=U+pkQg:7ՊtSmT tIDATj[0RIíY%JVV7'5JMyWMjR W$+>A@-@C ر$7];zL4Y=lTƤ<Wɜ95[Kq!XSҜMRwkE.>9u& JZITˑ$kUS`Qh8y.7z֔[ܹTB=|ljT3ފ5\A $P $_ ѓK7^=rtE?@!rIiV'3ZwIV<|vD~- [j5"{wް[^YL*d;YTp%YbOcD-CΞ ]LvZʶ٫Wѣ[]\^rp(5qVNk"9|3|Y%WNuz+EZߺ)n ^jRYKA†rP q~V&<dɓUja7 =oxnwf w9R !„jpUmIbbbddd``vhCDK$VΐoI.Ըt)dɒ=zt(Z6M]V*ɩbᢧ` hCDK$ԴF A0$Yl+99Yl4222D!Zц$;;I.$(Uvֆn$$$[H333RSSh7D!ђܾ}I.$(Ủًd d Ifggnzz z6D$>D A[hA$*v[Dy\.,Z `%4$Y?*$OXrǏ<=I2AD "$#ɔ*<{">YCBjd3o(԰OCgY( H2*n\?:ut{C*.%*>A<j7z̥$^{?|zw=mFo\HEd@?Tr>"`LR_wW?yߴejϫy5n2{aQ$d$YgI90dUBjD+qd~8s6r 'R4Kト( H2?Py=R*`yoLqO',!- f,ũ42E58rN|!$SIsq$d6fD8礦iՈIn5Af $$kC9(D$$]X8ai$ frPv^IkCMd~Z_j4D%iks9`IOCIFae#󋷊y$gyOu%mVq(٘BcxF5JH2 Hr%k)dgh㑝p< VEښ Mk4L`ӈIݟ2vn4onGd}lQ˵'n( d*@G,i/󋷪o uub$$cō{8nvH2X#[Icx2 d||KvG%> H2ʭ#)e7D5XgH2 yHm|=D8at˲R"q}H6_H2 H93c-'pfQd gogmG@FwTō{k[$$C*ep ~R&s M#_燕Zœ9Ƴ'o5JtTy"#H% d*gds8_V\ڈ<.DԐ{flOEɻKȳY*8 $#ɆrPN$$U!$jDi/_B utK4&,D\&onhws7j.'͇3ql (e$y,H[DII )'̩{t!'=H<&?PG+Au+â[$$l)zsH `|7w|aًGGx0dC$~M͟ ~{ H2l]1< VNlOř5G_$mMP|H2l (e$ y9`?y$gyOBS,\n2$+.I@ A"]I%'5Z`.2Bc@dK@9({:< VNښ>A3{!HyrPE\IW# `$=<ȊK=@IFMClYaNݟ>"$h y9?DIFM&'l9H< VT~JSd$BP:ߏ;I aLSo0'k$ ɖrPVHV\_ PTd/#eҊ"H~"? $sF6`TH2 HrAUa2$щ#[2 H2l(e=\?:< 6IO5A,+',Ad A9(!cϛ@&8㷓X7ÜSI$I6>ut{C*IhBʹh.G^l9bxr h f@&~ IpIziHIMߏrPEH|IkM@b $d$ٜ$OXBx=RH2^'`I585 H2lNrߌpvTpqcF `%9ug0(IziHEY{cd nu,Lv™F!@/ I(R9N?Kb}VH2X 79,?H2 HE=a$oM< 0d,O9'DIF AYm< D$u9i,OښI+d@dKC9(KrEmH2 !э<@pX$d$n33֒@VȌ>{*y"!PG7=7!$#Ʌ,Ft!Z$ln~lDIF+A)e.ҷG' IIDZ V3z❏d@dwp֭wQ 3cRFh,q@x(MmJaKr5"^oH2 HE*0;'̩n$lIg ` dťvDIFrPQTRHik<$lJ< $#+Fz7Ǐo֊$rxP2_p@fX9ijd@"`9(]I۷(K(ѨQO>ٳgZ|޽{ݻwLҰa*U^|nw/ZxH2 )%L DIFԮ$?yM4ٹs$_~S -?10^d9gdII@de?zڵ#F%ۻq>>> s̔ =\w}gjTv݀$mK: s$$[zAJ_ZbEi6W_}UڵklݻG՝,22Y_m@8{Ǐ<Đd@"CO9(-IqFRܹٳիWwAm۶9cǎʕ+%Ky%E$lH2$@$(Q"66Ç 6Ԕ;v<}t„ qVV\/--xd,~4@x(э$ HrPүd+j׮бcG///-IO6_c;w̘1矯XX9""ҕpF^ V|tKI@dÜ_5q` )Vt@aei/_H2$F*ōjyD; O W@d$(DXrP qP!׉dE" H2lBҷG=$y,H[DIC豐dH2$T*RȓGaNf @t0aLH2$Ɖw>.f\IdEN{2d$I6rPypt@F92fYH2$ ys'̩;cI[D]nOr @d$d(ϛ~3ty$l H2OrPAFē@w˗< $#rP sHE@FI43#4< $#rP J< 6Jx3 H2\ (%seCdEYy$IF e'$-r9`? $#rPRd]R&H[DI@d3@9(At!7r2 6ʁ6@I@dPA夦 I4$lBݸad$I6'Źԙk d]nJm8y$IFIq.Y9Igf%$ H)堲R$l>OI@d3Ss0;Od]H2$"ŤG} H2.ut{T $#ɅKq(% Yx25 `8œ< $#ɅݗzfWIFs 7};ky$IF-}y$|ʀ$M{ՈxH2$[.uu"=K@aA@d$٢k9Y5z2 `:{1H2$[,u/y>\@@d@".Ah‡ H24>< $#ɖA>=~?>V@jTNjy$IF-˗di utcn@d$hrPMpduH1< $#ET㽙t`I;Mt# H2 Hq%6>ﴗ[4I['Ǥcd$I.JNy^aw$lPG9w H2\Hl[vPA|$ud$d$9`;D@ֹqh H2 Hr#Or9uŇH2:'@d@d es}5"r)$}uᔲ$I$IlT 6= s9u'$#$#V͕ʻ@)ſ|v$},TH2 H2l]V9( f v6[<$$[U*ǤX>5@y@y$I$I:lTaNݟ>#$lgyOBl^= H2 H=c+QPdrREIFIF >~_*I;!'=} H2 H$Qt!\O5zDH2!cAhH2 H2ldZs9󋷞c$샨FoNIFIF.V[*ɐ3|F$ MCHIFIFԴf[WŎ I$샫G}DIFIFm KA[̌ȧH2M;x+y$I$IrߌpvA{(򽊬ߏI&Ev!׉H2 H2l3\K4Szݜ/D>(ɊK9ƃdRd<{V* $ H堤5{TWq',ae ɤ>t?$$@MbjԵ}߄蹷{ .~F ɤ>H[2iy$I$Ij>8廧^4foo=AEcW#cx$ u9`?y$I$I~;yˣEwR5D{8{Np8rՖn@!Dw/y$I$I =70A%;-y’"ٙBf$B# H2 Hq?#ЫKw<9"ٍydRd\ :HH2 H2lǠ뿴lX@\IܹH2lOe~@d@ds8NV\׉^Œo*x:aVǷr 3d$d$lU@_p IPG7/$I$I6$zeV\aw% H28<$$_={vX܈ BIrﴗ/y$I$IFDnJLLLMMo$'ǎXH2 H2$ۉ$/\)~vv6'$ `MIFۑ 4m0*j+#ɀ$#Ʉ]J論;I$ H{N5q&87p6\*lshj߳gUŊ.X~׮Լ755ɩR^)yرM]I$I&Rg-MtZ$IFܻU߿/B{z%y^jZDDqnL]I$I&(4H2$'BuKޓ֡[^*UuI޺ux\bKc7%%nݚOhmʤl|399 v}ko{bc]օͲ۷ǽBe6m`DP9OǻkߎA^ueV #H2\Ľ&}秥@kg$:5}U>NNv9zž޽;o*7iǎ/ϛ7QhDP'ONӦqЯ ^n+3$ٶ$Y|GvU GhB{[ނǷib9$>q6"ڙҥKI-XA^x׮RQ앴dذ7Z|KdI@"&yL_v ;W,/Nk,ڼdgRײ+UT1O[}Mʔ)-6[>qbV\qР}4'3OZD?Ht.M] ɶ"OJ>gO^˖Z9=?f|~['ONi-K-ҠAmG Tsr>_>ȣju֌(C\7'/ӢEc>}\%3g͔3֭ݹsZ/\juW6Gn9Y4 6폿9&&I+K$]V%A-\Y^-gvWqy=zt;ŊW$ $wI%dKL׷u{ŠK, 8Q䆽o&^>>Sׯ-A|$ C81#XQ3 3ݻA}4ɝB\_dcǶ9kֻ;~;|0z*b;bK+o-M/JԠ[bavv\*OZ۪Uz[o7oI#r!kCl׮k[fA!ҳK+ϝ;SW~qX(6su#˗/{O##׊wnތ)$!N$G5r٬%]ŭ}%Jelr!Ksf{rqyw HScoNG4?#),K O|ɽzuz_[,tv1*USᨯ7W,[6EZ(#{ gpP ~o ו& : 9}񿡡+ ݸq]E(LoP̙cYY+;6{aqȟ|}/o۶ETڐBǓid$I\Ҷ ]T))z-$#Fi[nv]Wv\\Y?υ 9Z*1C_-l}V+ tum#?1bDOr΅;yH鱐}Iw\50s}Ӏʼ ss.]꧟"̷nݬAbϞ )[A\4gzvIwemڼ(I#͛g*EhE쪳su3o]d$;IPSɧ#6$olXն||P87z{m=_^#44tX"|NrMu96lU~k]BY˗/+<`ۦM09df[7Ks~,k^&T3#sՉX-SVدt_W4/_K1}[Vmn8toݯTjrLFqPb]7nx~Ta;tfI?|S4ŀ$ W%Of灶CmsI9O}bbI.HQliޟwy->L)=&IvYjUq(-_bsuG C..Z44 f&!˗ur$=]ffFu+V,61OX+4 ]KO W4iu)9ܩ+e:t%^~]R\Yz;qDM4!\]PҖ== $#Ojc+)Mwہ]}r(?s3$ avClreCSnoQ:`+CS H2$t_B$yLn7\g񎡎}|@򄏮(>\xRUwʮunlT~C};aϞU5kV)$IF zf$[i3"AW}f$ bkѱBLS H2$If$\=nuO~N44H2$#If$ꅾBtHw^?wP/VG>Ǝ瞫`hI@\V}bJzf$7<&G.^]ˋ^ƢKv&{-^RE٭ߛ~,={v7opڵ?~|RTիoٲ U"4H2$ѻ%$l%dKkctWXq%4Cv7u39ܒ;sFʵDf>sIb$#H2$ݦY!:"m zf$}zm}\WrP֡l.tIT[K?ry-„Ŝ֭i)N^IOߧx%YMe ߰IyyLOSN4B {6md$I&lcȱ7=#GC>:׬"ۣGV\Qجxܹsk$z%YƍIeI%955ɩdRc $#=3@my|'_FQדB<^Ũ2eJ_+~l$oݴiC2 ~e䈈b%[v4ŀ$ H2A dkF'{3!71N`f =_]}_UV!}_InР$WXҥh1}$I=NNxڨQ݃0USR֭9M1 H2L3$ن?O~w谅Q?9yt84ls?慶Q~E˔)]^ݻ?5(m۶زe;mژ7k ֭ES H2@q^z)n۶m۬GLd@omy ͘ .YdpE׽M'um@^@ 7NNrsЕBK(!n}a+W4GyoUXN Oϡb|H41}jժ&bABd@$O:ommLP'dd@d$lYV;vHOǍץK'Owޝ2eJÆ Tҷo˗/Kܾ}[,oԨ{w=^zu錌 #FW;tgd$S'd$@id$Yߕd!*Uphk׮}ui/]$x_>}*0B˨(5IΕdmZL3$f))Fd{,XдiSʢuLPʕ+wW\'0Fն$3@ IFI.IV'ѣ!222>>TRϞ=ˇ$mIg$M1$l-Ӟ={2N:[l^rdɒ7oZYJr&MdMII%]v$mI.`ܹP\層ǏOtf=3@ I$%yѢE-Zw^\\\JΞ=+-Mv~߿,>|P, _{ .ܺu뫯I:t_|G$0`ڴiyyy$;,>qٲ7$W^E1Zn陁K2 `?5y۶mBϝ;''4o\Ν;3fx+VءCi4hPZ_Z\BVZ}$;vLltjDc-Z$unݚO `ߒLT$N$_{Ozaͨ^IUcǶkײBr/}dc\jz:5"ٳJZ8;W_ܫTb '-렶MѰO?Ej]IZۢEcG ]ǽ~LԨQ]y?}GߔM=$}K2 $۹$rػ{1yt'߼+MSR>kj8qeb0I_t\4~-%>b^)\f1 '\z*, v|#憎]1L2M1 $#ɅջNIPR+|S^^֭ /IfBCI)Fd@ddngv/[ &mѢׯ_/?pxŊ[|)H.\lmYoKw]xXO7`Hn1y0X]7{ $#H2$#ɀ$$\+ 7˕sظqx=)H.\Ov=4ػ5Ls'L6Fm$7uu`2]IV;"MI&4H2$[H-0ڤ{ 9XCwS\ H =}\=!akέ/nAr˶+nbZz6{u,YXwj7ZKՎHSI2M1 $#K٬9Ldw)njݺYgGI6^%^ZD?kɥ,ۮM㋽Kرm/觟"Saݻ֔W0H"jG)$I@%c_˿䬬I$ټ[\z4ػH7llWwum#1E IՎ$I.I)Fd$dIN ?wz%WǦXXq5=^Iˍ_SDÆRAcڴ./Mhڴٳ!I6$$_l6/td!cn:G,o޼ьo&;neu\$I.I)Fd$ٴmy+ :%ʻg A|ZLժcb6޺0yHI}hQ\SQ}_nyyJ(!w N;? 8n[v`EzfP %٭ r?a(&I)Fd$ٴmuo 5eqb)N/.=*-k*ˍ_SQL;?~I6uS]\K $oH2 H2l?\ja1dI=ʡFJ-k tr$-%U|k*34%M5kíI&$d$ٶ%#G{ɒ==í(ʃhQ\SD͚U;wy{Wm;Ew~#[)LH2A ɀ$ 6/O|^0c 5Ϡ@'REqeb0I_t\/7~-%>}\o8iB_Ǎ(B<8vl[v-+T(K޽EU]11RjYeK/%5LKs,ܿ]PApD AQH15FCDBDkO;33gya;r朹o׮Oti뻸[J"x{vZ.9pk#G%;)i #G~.CSt^}ȼ3Lt1d@$[s$Gn[^s<^sP}\~MrA &Ϝ9VWR?{ʏ<-%ſ[+W409gEoϧ;]_PNk{G~= Ho\DnJ#Y2}u֕}Р޺|?w/eɩ^MT"Y߉T7i(9|$F"98xszR% $M$Ê#{A$l\oѠEwگ^aկ_OI}zΧNiG['uVkHO~=+LSd90f˧+EEz~ݶaa%2|;H ZYi$ԛHF- .~C}ͻm^jc?Ԙ zӼo@ç:l>?y*h4 Zg"d"HfHٳKx8*jCuslcHVV2ԕ+ /l򑜞S efN曣_{EKD[g޲wɅTa][_oWz5RSSBBB$96Y͟_@^ߴвn%7* ,gffA$L$W|gt{QvW|˖MҥC5kye"Y%/]OL,6m,%/2xc'޾}#tS>M-,@ڸ/t_ny*X;j޼Zq\iwIwt7;\\ERR\F 2 TaadqV]Ysrrx H&;hC\쳽 *.g99ƜSvL"Yd8T|K233ZRSSX]s?*9k`fNWYheѕ8??g"d"n1cLm7m';" $0rrr4ZI;yqHMIX8Y\eEWࢢ"=d d"/JcNHjޯ0+d d "v۸򑼭33@$ "Drj7n/3Ox DuF mj!_ 'A$[끍w9!0- DFJƷZVw -B^jZFM<„D2ƥߥ(#gcU[-T͔#h]ri߭sL}f_m&A$\qZ0>Y @$: ״_{o{Xw$Kз6 ij, &~؜3 Drm*C!!!mLbu;Շ1󜿩֐ id!*ǖo1S@$F $/E&4QV nÏ?ZCZ&\]&?''DzdǦ۸\:TD2ɵTff&$$1m-䕾 g"\5deeeYA$DaVn`oS@$^i999RkZIztL]ʏcO2222EEE, fqIL?O6Ed aj (}̉~vH6׳)2@$ 38` ~QNt;@$ޅ$6Ed ag"F͗'m u-/Di HyV DJu D2̠('o[cgV D\-;"D20¬\M)dH6Դ`d a6ikH6=FoTD20HG +k"D2D奦iݘH6y.wd ds:4{KfQ\pmgB H"H6%v Ne0lFɞf"d"̲"&1lFam\r H"H6Qّ,@$э{7o @$L$Yq5Dy8v @$L$w @$Qfؾ\ўg9d dv"PH6k{+XH"d" Neغ,.a*",rij@$L$E*f#,! D2 ;;7@$(IGmdd dUȎMt`",7lwQRx H&U!iRvfQl㒛xe H&UCL@$^¨)^+X0"d"Y-Eu<D]Nyhq5 H"HV؁^gv3lb7KF cd dbr"7l"@$*rlKH6yCl]cSX$"d"YEFb"~^?l@$L$ȯº3lb25vKMcyd dIZӒ/H6QL\@$*r$@$L$N¨'mc"d΅I$D2 H&UZKQNS&;Ћ"H@$jt{3l2yi6. dD2FS΅1l2Ixqd"L$N[W. &S#<L$ d9h>@$OKdwL$ dyh^jɦQRx=n0d"L$Qvl _ɦt/]vD2 H&U*i+H6='.H&D^q)e*"4Rɜ]vD2 H&_^Y@$̡ke@$d"Ybz <Di,.ػD2"HV; M)+21֕H&D[)s9&<L$ d5 wxyd(.sq-5@$d"Yu$ú3l2Z߰~<L$ d5JZӒ/H6)dvD2"HV!W3 H6¬\D2"HV؁^@$w$ϣ D2"HVna@$<D2 H&ը('/ƥSɦq$3c`Td": 80q1&4{q Zi]dD2l"= +&shڣ6 #ID2"HByi{7 M)aįLjdđ,J'o3J@$sx M|JI&"?_FRndD2lmn[ՂSL@$u*`\$+ܮ7!D2"H6a0lJ{񋰔H~נ8qm&HN&D?<D)uut$z#K.}"ͼbqo\;H@$VF`d?^wx̑r3g^79j}N|krQM XwL$ d롱wt%y,l{_xĉȁ;+[oNQ޽ogMrl; ycbԫWZҷ﯁˿rw&Ym 2~o#Θ0oQf13)}S~]%'$7p5_}xxxRRV'H-.D'9yd;?lDΝ/Ix$'OSC&d]TYҥKk]&%MT"9/ՠ ~)պjh#uֹpMMb`M7f\>WenI"ۻ5Θ0쥓_,[dEѣ999D2ɵE´5@$<s;,QשSΝ[u0+?u' (?Æ/a>ťRQQ\ѱcیdCv{.ǎMLL/?ʅ9ȑɹ36l&r 2³GD|m'i-95hP7/offM Z$yg={H~a-&YyƜtv7Or6Ƀg8k_}뤤L"ZZKQNS&&|bDŋݗ32>@*rkҥM6x衶ֹ>n-ծ] =T7y,6k֓k#&/ƞС$ߔ)ݻuۯr2³_:uҺIruʶe˯r$#Uszo޼ᣏ;3*9{d-3F>:opFȑ6i۶رeeiHH޼ysppp\\V%HaF~\Hf0Q]uҟ6&kdD2l by+j#D2H&dD2WCl]zdH6PYD2H&dD2GmLZ<D^ZOD A$L$׸&c"k)v"d32du{Nb]ǵksƍѼymknJ \K}kYr}M$\1ydϓH6}1èamrsg蔖};i'dD2ljI+j ;0qq!&̍77ۉ[7"H@$&URx=] d"Y MXSjZd"du/"nt -:~[h..nr!]dBԹSVƍNz@9irʑyy3رeVS Q.–̜6jTmuWqzdgOݰwݯ_G9{nw(s#?$` E+3ӧ?!wYrkG﹧E˖^y/%#Rᵗ׻>P[ݍ9~z(s*[Ư׫1wDwpw-xFFTk(sYd k))HHnݺqt_l3xOoQ]޼yر) ԕN+(xk߾qfuÆ?h}Nyʅ̜HvD߾r?wS\Y \{/I9vCrʕm4 Ș;e`no)W£eFNZn2\fz$%T&HJN3M$27^G~'1wjRc"ȋ*}Vf^8ӥKk]'%MtMr 6 Drmk򱰮@$[A$yg={Ha-??-=ݳN;F_2;>ޣcǖJ$GD|m'i 9/kРn^L9~n=ر)%G9/7|\4O]aKA+}l:h}yO1|}7lxCﺫ|{Zd7"mҧ/?oзp &6~ ..+&٘H"IZӒ/HH;I7oGkQc5kذSO͞J$_:uҺIrƠ wof=y6r࿎?sui;9A[&m6;! 丸<Ц~]1Wx}74x׮ͱkֿ'c c58FF1UMN}wAH:;7mࡇڮ[Za$``UbZcd@IuL@$Wښ +Wfygc?E֌D2XI$<<A11<H63~ V0nx{ŊB."H&Dň6G<DeǦ7;"Yͣqvvv|D2 H6h)ـ`kL@$]q;$ PYD2"YEbyfǦ银&@$vD2aM3}D2"lbz]N1'у?"DzȻ%D2aM\p*H@$ȁV4a$d""YgdD2Ff=`걻#D2A$䚒m/o;lժѐ!]ӧN;(QH_ꌠ""GmcT^fؾh)@$ʉ~gT90xpg穒rX[73-{eFO РNg|R饜Z$4HJ$L H6 ۓ&/1*opo U%'PӤEɓɽNOT~S~]ݏo$$$W_!C̟;6v億׭8* ?z?| HVڰ*k*D&IUfMn:.Y>d"Y߆oD2"lnlߋǨ(IY@$Ppms]$m OwxϞ16hP&ynrcǦJL/?^2@$#t"D2 Bcv5#I'VĘO3 M;=V-32F|ֶI۶Mǎ}(+M]$ϝԻ} }ؿn-ca"YiɗD2aёl= H6{^瑊y.4ydˉ?՝Hf0,:EM$ H0[W -Dr^j`Xn$'M^o'|D2"9U] WH]ޯ3 [d d3BNSXWwVD? \2r {j([}"&"qQ#/Tdu6/LNHHh4LB~N~Zdǖo"*URx}[cڼﮤKOcYd˒VT!!!}&h&t򫗙ZKaV. HVڼ.yi ZL$]a&nK'z999~1V~dDZ}w4 -Q+333^&$$PM~N~u^~m 읞H@$BmwW@YP"B^,osrrV= //U[)k[/"l:v]W3Cl]kl2JȻw3H@$Bwױ囒=DE3f7G|vH@$BwWÈC,l Ne .A,6՘}dU5XXWwQH?zt+Gft$i﵈dD* ]`xiɗ,l#=a<:]q۬|"&Ul"ojRRx=jF6(@$[e^Wyzһ"=F&%KFPLL?OOH&'VMIHG3~E$ M as\"@$[%S5*f?ܻZdDeE&֒UE9y6.X>"\ўu?e 7Hv>O$ B13! R{N=*иz"f3+2HG I,l:h#`^)^+Gy""\:wcd+YvkϾʎM7.`ّ,"=94{X2"ٺ~FPLÈ, IK!WckD2"YEn50+Zv NedD@, v~dDs6`>aXDrmde]Vvl `JZ߰p'T".'m=?a;%@$׆HU$*ȉ?t_F$ jF<[w`,Drdh)l @P D2"YuU~CR LDr?!|s2P ;8{"f& iR{,&Նo@$!y  ~bzUo HV+kPHZ{WD'V`5!syܠ6H@$Q} feɢ0+Wcf_K=FdDiɗVq]\p-ƥ(' dEښp<1Uv$yLE$ ґtĵ7,~EH&K{ů] 7 H6G,!y.4E 2=ʻ|Z! HV6xbZRxE cz%{.g*2 HV+^?-;\s E;OI'*)d"v2b,wú| d,Oj(d"vg"M(a !@$ɕO5xj(d"v%Cl]fd[O\~l&BH&qpOȬ\0c!dR@$Eښ[q-SdDM<nـݱX"H%g"Bb*2 H =F[nK @$ɷ*+2Q:olB&ɖJ[ . qaOODr&ػi}Ø NM7K!dP'iA{=OHڰPaVnGrs}8 H X*K|8"YppR0KGCCD2"2dsEԼԴ<ɷZs}0[׌u>}d ꞛxD 6<B8caG fV;4nj$ HikFW[+,{ODr5J߰]]f*`~^0B%_N$ -Fq5yt5#[7\h\<DrKM 친59a|yP/"Fg^ͷppӟ`@$ׄk|VCXXjUH@$[yWbuUUdKGakFD2"U'mKXRHiyiRIwIX:d s!:)hu޶(IY,ud<>0q"\:lXj7XdD呷DjUǵ{7lJZ0 E(.?lԼ#R"ly7lݪ׫|bdtHzD&.P9e)^+T'u"lyJ VۮM5n|l^K0P'S9"l.(s{.D'9 lFg"Bl]^)CUddDEwY ٞ('\'cuXU0jCU N2"=ddD:4{>1(^Pg̘d(豐gf׹и[ ۭH@$]aVn;\{pGbڬkO5fqɏwvk1y" yZ7͈y.4 6yiQNY:ـ\ўv7h~H@$[¬[W?묀;4?W36D:,.9nqFYɦߝiXWwK\ ػˇ$zP?z,dA-K/ֺ֬N&AKNp,fdK!yBbF^/5Eq<47u?}d+ʍ1Zx[>[9I0P>zm2ɞˏ-bH,3S"=" ܎cy?`A_L$]5Y\BxӒ6.W3Yɖ\h\È~l5 84{rfؾZE$ -FFp̶FJ$]{c}r6ed"ْ"9p*F#/WIII򊕓B0Vܠv^gB[^jZӤ~ǵ<}dptsnwl?ZEFyCBB+55533 av?[Yh_k?z;x"Vc4W luPA[?߶'kk}jq{tFIHHj, +84yi>U@ـ{D벉dDE7~H9o=ۖ- QS88/QEօ+%HFuRWŵCґh){Nȉ?ldd)&9 3vm}hf4d" B7.]1np9&cB,L\.`옍H@$[a$x~C+-FbtdTYI5a]ݣ MkYy$%k P"M;-S6}D'[ڴlԸQ~aN ko׮ÁD2"6GWFPL@/ 6sBKGҙ "lv=4,xOBќuQJ ʜ&p{}np=e%D2"?MZb?lNVd"*Y 5ngv3D2"ٚ#|ewSfMe-7IA3>xi㵵%ɨ.%7ltw&z尼2L?΅ms`EY qH&*ɳv-XT~_{ueO997Ny]##>բE3"yٲirL-_}ս K$C&I5?%iRe^ʋ Rd"Hwx!$2;`@7 Jax^0&x{̙7<|]fMRRHvt|?&/~Hx%SwlF9M:<3yJ?z2,tYI$dUGrP>wxr:UuPo1|ׯ')ǿ o5N䠠ʑ۷>"HF7K2E=<_Ww+0 .D2"Y,V>}3>w 4&CB|z;~GzV䯕#FBHx%aA"9gcBBBjjgffD2"Y-5 2sI@$HfhF_$!dDZu}nw2ꂩʎYwܵ|vjMFny?IXߴi~ks0vXȓHx%QA1o-gҶ.n/ P[- L"H@$%=ůW@I&2mپүg|V*ػo%1w{gǎ5'B xy<믗ntccb~O$<ɨ‹`ۂ7o*4}[K7`cpˋ 1`򹤝d"yRy]Nh4 ZH&j_}5`eWN:wҠa "TxɿE~9/D2@$Ê#?|7綮ڼ5][H;hC@[KWR.@$גvv/wyglaӢaeiҰ/_h߾ճy1@$^w խ-})}u2 DW#iaM/pb H_c^0skJ qևG%Cze"dcpw 32^0Y歝{Ű^Bݟr*>yk[_Sv:)mڴJK _Ls-U'e"l|rbH#;+/{a7~ 0׋ <15=thC+e^2~;hժa_L*jw˖kѾ}JܘL$ *N?_ڿ?zNzmE2 ɰH>ԭ[^{t%gο6ߏ׶]:^%cϞnR w}޽}yŋ161uH@$[a$Oyp|VD}Es0TYu㫈wRCĶOdEpķ|Za$OY# LGL|Y`'^//:/VXuݡCɁ?~{HW>6ot=yBwg#Zhv߾~<{vmڴ k[,Ϝ9U_v}oZ ︹5CJ}QQfԭ6oذ /k׾/݄ovN9q^^}WD$d de׿l"cZX5-WԼO IgD2@$#yC0]'OS9p} "yc'Ǎ{W˼L6^S/Yy[jjKǻڋyxugV˗I1vaٲirbosPN%trx;)c.3NtHOA~o]\TN,=|򯞙׭.݄S_w06pED2 HHf/dXD$6S.NlӶ)I)Z5ѱeOwGI[k־}SV:9=,uzBtӦמ/*u*ݴ韺|wߝ2hPopTԆ=* qNB\kWz-^3>WjcsL(eB$%B9wod"L$dOFp.YZԩGe[T~?{(,~zl):bc)￧4oTXBBr ԝ /(ce|K/]9ܡC~W>(8sزe3.|mڴQ'xHIo= AKճ˟F M|J\D2"H&ɨOodH3;[e#}^/nР~SIJ1JSr弍5q#Y9KӦ:KMR8IgF*kZ x&M lޟXиqwֹs}*(/޾޽_9nB֯7d*ڵs]L$ kA0fYI$D22wtq-ݟr*ɍ4;?*4z;z12EwG~Qs-^Md;ʁ/$.|MN𡭭ͱc+c.3ΨĖ23V%~B3ǻիWWNP4 IJkժyjj@vٳ;8td"\@."IDAT:dHEriۖQ7~L_r̺}J'/W!>8`.xcDvTO N5ktܾY&<.W+tgQ~%%?4m8##R[/K&*~ŘK99INnM e{^iԨKz=pM+fٵi޼UH&5 m y0f^A$D22ŎD\I%&鎼˾ʔҧ~[nRY6fPD2 H IdH.48hkD}c,*tR/#B^iHs6=cx:^d"L$b HFuzFnM;]nGC:s6jx]&dD2̋1""箱soNry o^"y]&HVW./dTE(>9ȉg{?ЫD2 D9XHmڴ:|8V#Q׮}oZJ'KzlĮpiێ.nɞ)d"H"#'"-u'<8=ogޠ+rac,8Q=qn&2H&ErXf.kF?#FG͸瞻,,?O<Դi۵4AA+;uήR~75j.c nѢ\\s}˗"(' I Yꭻm߾_> ɨm_$zL ܶƽA;4y?V D2ԮHis'+=r!|QvWr_.(/sscy[J[\I8>uQQ.]6mHkxMrbǭᰰ;thǚdH d +xȧ}S?( #9??^9%*' 6ڵyI3 HmܤIדdH d mhDݏ.~gd;ӯ_ϲk+WjʑrX .̙իg˅tb7~zɺ#5kYdH d >ؠ6~\f/Ñ|bƍV}Zb۶m-G^o޼IJ$Wxҩ,+*a| ?n]Zɥ/HdXw$^) '|VD2"D|:Q:rQo|Fd6m,Ksq ?9\N] d˖Mkժyu|$|" aݑ̰A$dSD|<ޢAJ-W׷7.D2"f2,t^A$dEr }1 HF|dL$̋1"D2e"d^ .@$b HuH dd "H"o&"=K1&H.o&"5H">iǺ"6K1&H&dH& D2ɼ? d"2 D2x1d"L$\=ikWUGI&HJ$~Q/HeGٷ'xiݻ߷k'ṟci…2|76mZ)%UܨQC9s}˗Ux9;ڝ:ݪn:9H@$AߓL$lT$o>ۖ9鹿w}^sscy[JQсs]W$6zDFN.;ŋ1W&*Xa$^\PNk׾ݰرc H7.\.E>2of$1bF@ݧPN[gX>2D2 HH3b7o.YW5 ^Q4_>ܤI 2$GjV^NH f- H4mqU웽wy[3.Ƀ(V%dDrGrEދz_Kb?3irYEEz~ݶaa8\\,}L~~M a]$ׯ_Od}X߉[ aTYy$dDrGr/ [gn*sclmm6n|On^{Q2CvǭzYm۶py&"YNvv8a[,],;@$Ȃ$KF Tæmgp}[Z L:@k!<| CC${>ߝ^ÿyaiOٱir5k~nJJ*+˟R޽_ٽaʎHEeZj츫ˉBPyK`Ud/33SԄ=řGm U~Ԍs{Uwmko~*'*guhPqR99D2"Ɠɑj'o{s'>…>cҧ߄7đNZ>yQʢ""H@$H$3dH̫\+}|@ IU2g {=L$L$L$C$H,.)_=F_:^,sƢ[׼4fH"H&"HzfJ!S [Z߰HG D2 " UrÈC;U+dM¨ZfD2xI$CERB^7 `d"D2 6Ǽ[Y2 "H*}5lL$wD2T쫑`d"$jX`d"D2`fjX`d"D2`NY% zV#fD2df 6K&@$桪 6K&@$fY2 "0 F͒dɀ奦|x)Wo,Y$ !eB!/X߿d HyȕƒE2"R%ݏ+%dD2D #ƒE2"T #ƒE2"Hl+%dD2#WKdq:FKd8#G%dD2G+%dD2O+%dD2C,#ƒE2"vO,#ƒE2"vC+%dD2PP?d Hd}#g4,Po޶ǥVi,Y$ nXH@$C2XH@$C29b,Y$ !9FKdH(+%dD2$iȕƒE2"ɴc䈱d H29b,Y$ {CJc" ۾XH@$CBc"LFs\d H&9FXH@$#f,Y$ Pk3,d";e,Y$ D1,dȻ`,Y$ ,wXH@$A#d H&8Fd HFNd HFNd H&}U?q\'c"LquHd H&ζn$?ӟ-E2%dD2qvL3,Ėce,Y$ -`,Y$ !c,Y$ !c,Y$ f,Y$ {XH@$d H&>#!c"LL8FsƒE2"pƒE2"9U%dD2iokFN{XH@$ޢIOKŴ"LZ3rc2,ɤ+)g,Y$ IWSXH@$#7ܪZ H&8Fn ƒE2"4KdҌcc,Y$ I'ayH@$#74c"Lzp%dD21r#0,əhO6(M/ ۺ܂¹m6dDrڧ+KdDrfEi7rUMܘl tD2"9oָ_.jHi}",3NYTTTVVڦb˖-dDH?0Mqqqț7'əֲmrcǾ掙8o޼7eeedDr&Fה?}Yf_;*i.7+g+o4`qW֜fϞB> HθHknzr&N{pך1werۓke=3nrz-dDr#>{=g{%;a}w>dne DH@$-n+)vCW)3ϾV?Ge,ɱs60DW Ĥ>ဖywW03scG~mۏ<'x$7-[W{'ɤ>8OrOgtKfxM/ FM9㌓[ڟ}V=s~[jٹs.S'pL3ʪj-:Wt9EۭmdRShkg=s%5UZiV'Ιtq{FnۺH~w)CѾ}Gse6r壃}>jV^c][0?[ڴŋiӪt^xc_m޼*'}mE~ҍ7yg~Ϟ.}M!a vQ}>`BΜy.NCϘ1GՈ_$т˟M?p$w"Lj -?/Գ~su~Kz_}1OTs;thWoQڜX :+䲲ǧO}U?1~Jަ)܂Z(Y/$zۯ9q룎 9W_$тJLH@$Bۧoa#c3D]=~{O9帪ogQ$L _d}͚5 >>IF&zoނD%[oeW=xuCv3fٳ[E5%qHE-xxofz&{'ɤОiyF^z.uq_;~ΓC9Iri?dd>Fh%zoSoA뼋HڵSU$ް)cظ{ß{:#9тWq N$ IMyAnc5ڱ o~u͵ m܇V [xfx.۫ر}tT6Xˡ{~;fe,Ѻ%%;f{:nu7 ]5m[ixMW| }<[|ptMb&9[:"O=Guixeu?IFo)HNdN$ IM-9yjզUX펝YZ=Qmڵ]h HFd'$Qhn>ɶO$ q^I}"Od ĚI}"BɶO$ Qh"dD2 M$> HFd'$_h릺I^O-pd Hn*rd'TnG/Fҩ3O;fB]v\iʲi}"[EEE͛ kV&Oyy-}"_CT H񅅅|'FX2a}6nh lH@$g7NX&aelٲ> H!!6nZ}V#IX>_~-}"$4j$4q"ưbŊaÆ 2d0@3zz߆J$P6T"p ԇ H>5mD2d @$@]IENDB`libCharon-4.13.0/docs/library.md000066400000000000000000000064551413347326300164360ustar00rootroot00000000000000File Library ============ This library will read and write several 3D-printing related file formats. ![Class Diagram](class_diagram.png) Metadata -------- Each file implementation is expected to provide some global metadata for the file and additionally some per-resource metadata. This metadata is represented as a list of key-value pairs, with the key being a virtual path to a specific metadata entry. Each file implementation is required to provide the following metadata entries for all resources: - size: Total file size. Note that when dealing with compressed files, this should be the uncompressed size. For toolpath resources, the following metadata entries are also required to be provided: - machine_type: The type of machine this toolpath targets. - print_time: The time in seconds it is estimated this toolpath will take to print. - print_size: A volume describing the total print size. Additional metadata may be available but is not required. ### The Default Set The default set of metadata as referenced below, contains the required properties for the entire file and the required properties for the default toolpath. Virtual Paths ------------- The data of the file is retrieved based on paths. These paths represent virtual locations in the file and can be mapped by the file implementation to different locations or files in the container file. The library provides a method to list all the available virtual paths of a file. The following virtual paths are guaranteed to be available: - `/metadata` or `/metadata/default`: Retrieve a "default" set of metadata. - `/toolpath` or `/toolpath/default`: Retrieve the primary or default toolpath. The following virtual paths are optionally also available. These are considered optional because they can represent non-existing resources or capabilities the file format does not support. Client code should always check before using these resources. - `/metadata/{key}`: Retrieve the named key from the file's metadata. - `/metadata/{path}`: Retrieve metadata for a specific resource. {path} can be any valid virtual path except those starting with /metadata. - `/preview` or `/preview/default`: Retrieve the default preview at a default size. - `/preview/default/{size}`: Retrieve the default preview at the specified size. - `/preview/{name}`: Retrieve the named preview. - `/preview/{name}/{size}`: Retrieve the named preview at the specified size. - `/toolpath/{name}`: Retrieve the named toolpath. - `/{file path}`: Retrieve a named file. Note that virtual paths are case-sensitive and should only contain alphanumeric characters, dots, underscores and forward slashes. Examples -------- To retrieve the default set of metadata, use `/metadata`. This would return a dictionary with something like: ``` { /metadata/size: 12354 /metadata/toolpath/default/size: 12000 /metadata/toolpath/default/machine_type: ultimaker3 /metadata/toolpath/default/print_time: 121245 /metadata/toolpath/default/print_size: (0,0,0)x(100,100,100) } ``` To retrieve a stream for the preview named "top left" at a size of 117x117 pixels, use the path `/preview/top_left/117x117`. ### Read a gcode file: ``` from Charon.VirtualFile import VirtualFile f = VirtualFile() f.open("file.gcode") print(f.getData("/metadata")) for line in f.getStream("/toolpath"): print(line) f.close() ``` libCharon-4.13.0/docs/service.md000066400000000000000000000000171413347326300164160ustar00rootroot00000000000000# Service TODO libCharon-4.13.0/docs/service_sequence.plantuml000066400000000000000000000024261413347326300215500ustar00rootroot00000000000000@startuml hide footbox == Synchronous == Client -> Request: Create activate Request Request --> Client Client -> Request: waitForFinished Request -> FileService: startRequest FileService --> Request: return requestId FileService -> Queue: enqueue Queue --> FileService Worker -> Queue: takeNext Queue --> Worker: job activate Worker Worker -> Worker: Process File Worker -> FileService: requestData(id, data) FileService -->>o Request: requestData(id, data) Worker -> FileService: requestFinished(id) FileService -->>o Request: requestFinished(id) deactivate Worker Request --> Client: data destroy Request == Asynchronous == Client -> Request: Create activate Request Request --> Client Client -> Request: start Request --> Client Request -> FileService: startRequest FileService --> Request: return requestId FileService -> Queue: enqueue Queue --> FileService Worker -> Queue: takeNext Queue --> Worker: job activate Worker Worker -> Worker: Process File Worker -> FileService: requestData(id, data) FileService -->>o Request: requestData(id, data) Request -->>o Client: requestData(request, data) Worker -> FileService: requestFinished(id) FileService -->>o Request: requestFinished(id) deactivate Worker Request -->>o Client: requestFinished(request) destroy Request @enduml libCharon-4.13.0/docs/service_sequence.png000066400000000000000000001302501413347326300204750ustar00rootroot00000000000000PNG  IHDR6)tEXtcopyleftGenerated by http://plantuml.com09zTXtplantumlxT]o0} -0u@mt2hd[ n v2RsЉόںJl.T} 㵌ZI 4a{!PZZ0'F-H)ݔ6 ?]B 3$CtǨSck"_M7V+o/λ%iLmj9{Ŀl6BIZ~,BX5NzڗwWXjVAYO9bIs׹L8K*&=\ xhFg)+NoaLk!#V=2nn{Fzub3np\y2: Y^xIDATx \UuٕUsAˆП66Ƹi)jBMMӤkrK3TVX9¢  &2Z* ̜Žwy9pι|}~{x(-7V4UQFI4$!9-uM ZEqB j.%gqhH4BsZIW뚺ㆄ?i33؄6$!ۙ-D-Mݤ[qCB %АeI;&0!l &Z\mi&B,Ǐ={L RFLZZ@,D*>>>111##CL RFLZZ@,lxkRUon$`5ږ`BкnB`Q}܄`k7m$U%G&a_m &-kf MX"_͛Ξ=K&a_m &-kf  a_!g e !@N Z ر~'.%-XۥC~'$%}}Ҥy~N=ex'ncggy4 օWSyH#qt替5'EEfϞ*Nկ5*k.rrZ[{Ty+ RCzI9xCpGߠ雯_8a5>:vW~F`pYy󘤊=߶g' tIRNN]1T ל9{W_-98"RͩA jWǎmҗzm"lt`ʟ 4ECB0}cʟ @qC/^˟dBЙB`4_?lQWUrح4J<4@Sݺ:g/#7F ݾ3Soٕ.)gOU]˲F.9wͬA}N~iѤBe n-W^yz1ݽ 4u#X藚Cʟ @׈,\ mFB G^nEEy͚3QD[!P?h*Ask_'z=DG#4෿}^nOOhꉄP+7o{Hng#$z"a /!#$z"a,_!# `A"}!H !@D XPB@G'E8B@=0/BǍIR"F#P0چD#~;%hqt >nHJ0B`۾--B@7m!Kək_Gjk[[^[.EcD[#4픖`ժn-ĒU[o߾}Q"'&''G6f!l &Z\mi&B,7-Ǐ-ld)ɉItcnH4B귓[)WM ٳg322,ď 9%919=9r1c FHvrK0jcS7b9}i!!R3JOOOl,JlW$+**HL;6Nnf4B_c*-66k!]Ǜ~q")m=mW$kkkIL;6Nnf4B_c*-66k!]ǭ:vZ"/ tʁF6N9p43)!8̀JjK@!`̀JjCfT:U#T̀JjjPWPm@3*r0hf@S\5B XzڛdY S?웙d/+hox b 16+!0f=k[,}x@2h$b,W Bϧ?~ٳb, 'ӧOOLL'(//'c}~73# 44tǏ/..&c} A޽׭[gfB0eʔM6ǧYf{/~4+͛7GEE%&&={?^/^ĉ_! !t钽kD d*ldwU6˛7o~):wQ/;;;Yg0`@׮]W^yVڹs,o =zT._\6]ル3ghW6lX~͛USSSy!%7k׮JTrڵkeyРAWWF ?Il0<@sA䳔rlG5'SNPٵt!h;qeX+S ߫W/y <3{#}}Am&IIIsy|ep2PP숃Gux(63)#G\vmVVɩr}) ,P{:P_Vn4 337MK@n h6OS91א!C/(z'U!:tDbtqqh￿lٲ_]_Jl!R-Ez_28!XQ%fO&O|I}G}Jr5ݕ 䀎='|R!Ν;'ӳK.M^SڵСCWXG}+Э[ѣGKiWb@w?~<===Zs=׺_*|);+|өS>+Wg{[n-bQr$BرcHN!裏 }ǟ9- 40@gRqkkOc:& \!8!?ofb%oBj%;"K.j!0^!R1FLU}A\5B`b,99K& 2ethY16+!`XT3KOO߾}&h$_Sq:fT:Up vHLh΀-}, Hq qlrfFJj@l`#~i͌@9p`BW}'lrK9)r:So w3;kJ/7Œˁ7I!h ĸ>2? `6ep@Ru6lHMM<0Iq{T{HB믿{9E Q]XvvM! @lrMR[(!!b U#ͨyH WmBݱSG,}Byj6(ڜ@{G]u rx%P\5h D;}X c@,E!~Ouzl c{16>]oަO~c,+#?,ܢeFd[HUb{>Vq:F,Bԩ u1I?w NClj0 !AϮcq@U  ̊sBʁP!0yw'T(ڢ!b U0 P\5B B Bp~BʁFx8 P\5B@fذa[nmݾVVV#BʁG ;i96BʁF:چqF4?T[e!RU#B0o޼-"t2L+rMR3h eyɒ%Ο?/_~[]],O4I[[[{zz.]A^ Soe^VVO,oFB ѣrۧ9r$<<.\Xp[d}@@ԩScrssG f+ 6H.cY6m|G}%5G4ƞ?^ N<)o\Ӓ -VRF)**FR' eA4EY/*&ze^{&LPZZڣG.]:uJ **Jn%*ϯ#vvv[wCͺlbxtS;w6r93gfgg1%%J ]NX.Y=Q!0y!00C}/+wC]t=ܣŋr^YYаzje@ҥKb3FBhij0%!{1ug}&Os ʕ}qpp])iii3AiiSO=իW޽{?jrmw!qƊ+Dtdce+++ ,S)**k ckn @JjFĺ?>/ P\5B˷wHIB[01!` K1nkBK.j$N ]^юcb5 %>/k8 e2F"(O͎q RU yLlǾkJ/3Be@Q]G| i9$ b@훤p|lQ!-P]XvvM!- @l}!b @훺0 P\5B B Bp~BʁFx8 P\5B@BʁF60 !>|ƍ?~m|?Q.@9pA[`޼yP]]؜B=//ϰ;N3/ !1r{uVîyE[[ʆիW^A4HѣLJ~(666/_ntFU!R240G"""Ν;W[[i&{{*Y`:N:))IIOO'{5᭷ޒ}oܸ!SL|ƍZnCH\5B@B F!`ذa- tqqٱcGUUܹs=<}KΟ??j(51;IHHsM6WUUi_} A0PꪮJ2<<|>>>qqq)%;899 :tڵ(N(88X{3y7nXQ>766Vi Ͻ#[,}:6睸Qw P\5 @8!uUsN) Gͦ(W eH -h1.Q~d A_ߌXь u٠i؀m0!`!n?zOEi㛻n4HYV%˦LlK훤!Xʫ2mê>6yB"C@]1.X)´Fuq!Kܵ8 R@Z^Iλ;Lb%oB!`Bf(  ʁmr!B@j0F!` @lj!@!@6L-Wp @ljj-WPm@緁szxxtmҤIaÆ-Y$((uРAP֗frȐ!k֬*//SPaazt{b,W Ёm 44488…  .u"=Z]]l2wwY0uԲ#F4)퉱\e S?!Ό-J&...VJuss;q"VR_rE6+** e t{b,WmYB@gbE||"[nU6ΝKNNvvvVw3,:˗/kI4!HLLTKܙJ7pn ЁL2e999򶶶VȲ/]tQFB0mڴPIƍS;~K@@*++ g`@2?prr:tڵk-Յe1I57M0!` `BT;A\*NcjsL(6~W49;t! @2@hf.vF~ʁa|\{!Hm" l'}׸}fz\s!H}{"Pv(!~BĖPB/5UE3LH6@[*`гGR, `Y6i_CBe1n{ !@P ή)@3~BʁFx8 P\5B@BʁF6@B9pܱRWu=}7Vj0 !` ɱt_PmUq BC]v5V@S%BB5$3k%`K`lDZx @!@,˜h1юc07wX/̖ `B@{:(\+kwONU ߕUYt: 4$}1a~6kE9B$6_Q8Z ! @0\q:UBB`)Z^Iλ;BE[(!@!@! S?!b U#<[(!!b)ðaönJ#T  B@B7nB0~x++k~ʕ+$}~B#,={>'ԾI B`lYY3i$Y\tiCCC#! 8qkmUUܹs=<q-=@AܝBѿwyGݠP^y+Iĉ4445K>;p@т$ @ ATTܯ;;;+C~~~-cv_!%%JxUd!55ɩH{ cƌi S?!bȑ#\{E[[J_zlSO^rEYYPP`ccseêB `ggg8..N{@CYYYBs/Zfҥrbʳ NG ꪮJj@!r($)q}&fSp煀3ˡzU+!)l7U!@:G|f.q@ߐ(i6 X1INc"m}(6` /!@s-䟯}g?8UI]dpʡ0& v5] }v!bKg:`epsY: @liV9q}+NQBEõwwKBUS?!b U#B B a @ljC!bðaönJ#T m9_!!!DH9 `!}FD--ȑ#ᲥΕmm2B^ŋ,[LVVVyyyۓ'OےY8w eV򱛛ۉ'$I;;;˂:WjX}B : t@I%&&&*El٢,'''٩ݼl/ֶA)))VZ˟_ÇϜ93;;[^֍h]_h"BuVJ˗/;q#˽{uu-&?!B@ocƌRL2EHvȐ bi&{{*+[->d?r0r!(--}ꩧzջwg}\Y/?яz1{l)(_C(oܸb ///GGG92@ʶΏ6! @B9@"H] :rL**9:Bwytyty0 PBAr@:- @lN [(!Bwy*B@B9ܩ.ݱS_>F\ 8:U]]AOQ]:+CQ1cd}[PBu7#|47Nl|bl!@< !b ]~ټnxH1܂<h$"<[(N$%*k@t?!B@",_+WG#m|b܂pr.PW ĺ  :r.-$ݝKAsa @lmD PtyGʁ.iʁ.iĖ;^III>>>)--*//ot{GGG-[bqqqJRRR @7s!` @li7nܨ?~mvڿgyرc!0p:i8CRIQ-[()))VZ'''K"Ww3,#ȿ%5Μ93;;E#gΜ娨(ooo9~~~QAfcǎkԉ ;+  *lݺUY.((|a%=+KBff"r-P77jy;rHB0k֬{キŋv/ (Qf/֜To@3Bn*6ٳgSLatDfcǎkԉx f#L[*666/_6|7g%yyy)^T@qTnݺ%V]]-oGiXf͚u644\x666Rޮ^ZE̓lŚ h^csF~ߞT8{lbXGUC]}0eʔɓ'HȐ^_O~ҥϏ5JMӦM  7n:@q"""Ν;'˛6mCBB,XPWWHn޼y̙W_}U}@nĭj<;^z9@5vܾg( BЂvu- _;bs:@nX >EEE3%899 :tڵ<΍7VX(+,Y_}@Eҥg!Xre>}|}}ϟFѷ"{}8gΜ3gjxTr[!h]U΋q %k}gpTW R#ϾvU;Bbbb?]N;'FEE[BvטּՅejtX7"ӟZ*rV;dryJeԅDH9 A'P[~UHjo➜=$X<5g)-U9@'DCFm>}m,W+^R^C F dQ* /ywQve9)ȲrG%l  B;:}KΟ??j(5M6-44T$ ??ܸqIHH,˛6mCBB,XPWWv!P& };^z'ynvNc#J}l\ (< @t :X u6~wHR1.ty@@t` zTlq<0ЙE0gX8 &#+..j2hРb 1.t3|7oǏo5={޼yE限@l!Cs`޼yA%K,1cFK^z9s[tyEرjܹݺu4iR~~YIIuuu2dȚ5k<ׯ_? eիWeYq F?33Sքʷ:@sX^YRrPPϠA8رW=nܸzKEXnC=Dl!"$L}>|^ކ_pb…޷nݒSN-++1bDB8999iii555G oA#!}e˖+ͫ };%kN<7}wO$c IXy+Iĉd$9e HuvvF`xAllZJYrlSTTҹc+Zإ]BЁBee9%%Jdɯ.yyy@qdW̙3[=BN;w6:?H玭c.߷W@G5G\|6n|`ggg)** ֞!az=YWu]vT/&3{K))IbÆ 7&**Jl ++Kn@򑓑v }^-D{{ʀ"55Fֈ TTЗx!P`w[='vQ ;A`t(( jD+~Lb%oBA튇iW UB6@9pՀ! P\50 P\5B B < -2!:"P\5B`,`7nv6@9pՀ 6@9p`^BPRR2i$WW!CYFY)֞K.mhhaaaVGf)rĄ `ԩeee#Fh4BP__m6#ڛBʁ; !  6=xί $⇅D)rxzD+-q{92K.j$u$'';;;oT!*_}"y]?dVx>!ˇg,LR$&&*BpE[[ʆիW+3#GB``30QT'hRq~~ӌrO?}K.?~ԨQY[['%%֦{xx>$$duuuʈSh(c@y-}5_ذaCTT@VVVQQ`,BPW_ńy{;܂ph 'c+K q}JYZmÿOF' *NQ+P Wq-$ݝR!fXL+rMRx8:al@ŒˁGxJhܨʁ,t63@9pՀrxz>T'8B`B?Q:b___x?K=СC}e˖}g0&&FV:r劒׬Y3gOO1cƼkjz~"D/~񋐐ww;v\zuŊƍSկ~U\\~,l5h СC2+VtseGZo@1@ҳUr$l,?y>%Yꫲ,?q,2d$Z-SAo)Ӓqu:]ee巿mG=wڵk &$:uJf]6qDY /?LO>~,oABBB!0W. ŋpַ.E*aq?I!V{+_O)))2CmݺU*ٴC37Y\?Sʛ6mBeoǏ?x2-&?20DcʬciiJWV,V$&&v eb={vo~'܄]!!P}||'3<#UUU-TIggg)yΝd„ ǎ OOϊJJJoo2fy & ȱYyWV~7;= #i,|dH`5>ʧ \69r*]>iAWܳҟan?>+Hf_|f%$gϝ; o)NG}Dl"~7_L|K}bSO9::>ۆ`,|11RM_! Rj!Kb,!T!5gXJ=@`<0L  Lff'? .BX5ONMM}w}F*YZ*W!x|̚$moHJ%KUKq؞0mvҥg@WK%KUKWUU[,...!!!pѩJ=ЄЏv??6)S_>,,V6lXdddQQL;888::ʮ6m$111_(KmFqѩJ .& ;77ל:uUf5Maaammuԕe|!9Bodff|`tIIYVVJ.:|M^c!5߿_r0"55Ujq]Yl,6@e /~ sBL;99UWWwXڵk JJJm߾]1 ̘1Cs  C?;6t_ח.]jhhׯ_e`(… ,XpYmjj2}i___U֮]bas#kNÕ!غu=.66vܸq۶m=z֬Y ANNĉ%6 qѩJ -4Thml6.:@kDFE> zn3S!vCNAru=)]06#%P]=s^s'8% Às=pmRzGzR@Ip:!P:?c`wDɼNפCCC~M# xƊ껝} 0l Ot;v=#V' ؀ ,.n|K/gϞlsBlzۤݢ*Ȱ'+^>*[ pJ*/H4%z#[>Bzۺ0 P!蜖ESSDžƹ/N=!{^UvWۗ}\Dm`lK@;$$qH^s@B>nK@jzl}l2lH$4'Np 4Ot ޗ+*uenM#g\\}[[G17i RCO^s1 %P^@hoi-9>6wq@@=Pj{ ) %Pz jAy8 Wz`B)Mص B   -Fx9 P!BR#\6@B=Pj[J܃zA6m޽{V   -$$$//O8|M``,iGGGYsӦMʶSLY~}XXǬY:lB`B\\\rrrsssccc-<뷶8p@SNɟ!@ksC? ϟ?a.=&&F͛7we+@`@°2{M !|ٲe&SV uP>V`BpPl^㽼dvƌjjvڠA۷o߮!ԝn{z`3Bw妦}򨨨k׶ȴ^wttȐuN>kN B=@=Pj!hnn޺ud$eyNNĉܿm۶ѣG<8((h͚5愠V Ru C?4ThmlFB=P&B iTĹ/^#صr ~dB]8AlCC60@'8]Ibaؘ(~10;dB +z۪P O G>/^y y=p;8Dﯫ0B@J(5 0l@;AA]ݺR@!#Þ4@B=pm[]ҎD][m >@@v;qa!B`aǍs_UepU!-kRʮL? B}~PZ.DG1d>H !P{~W;8Xvzl %Pھ mvSK-GAL3ݭiH ؘH쾥/_av A҈^s1ql2lHT[ZKg|4sYs!' 1Csq@QWAdy8 v*M d!` @l(5B!z  P!BR#\6@B=Pj -F!@!@~!6m޽{{p?;oP6Ş끫oB!`?Bzjhhhӟg!R#\6F!~~~nnn!!!yyy$&&keIddL;::ʚ6mjooW2e<<}לnA-[ȶ2p t:IgϞYٹ,^dׯ^:sLU/^-PTT!0 !Hz޺u<JvOJJRL8Y۶m=z֬YcN:lA$+9r׮]2]WW;n8wwwY999YYTe ~6y;wB`r?],PRYr8!_B`TTTB`$yG|揵~Bt/Nr 6ŇLv[6)B3'H"2ix9{>B0`L:aP:{a!s!g8&{ϫS!0N⒓ؘiH!@IՆ L un 7eW_Uo޼)$r'NXs?pwwWJd9!p\RR#\6hB~e:++n 7e6>>^rY=8LI&z޲X(|/YlYAAe!p\RR#-!PsvqqSuuj3f0LJ~ @[ׅ𤞞&fgb j˫tz /[6)6*… ,X$S={VfdyTTڵk[ZZ/^-(<< /YW^9s͝9}\\\Kg8q!I!h]α;5uq]]]llq999'NtvvVKv uss1Rۗ 7n{```rr.--h4oM~#b,!@b@k!Ǚ]X2 C?!bKlדǿ]X2 B]J*/H4% B 6, %.z؝:;J "<{ƹ/έ|%GS]`mRBB`w@K+MI;$ZKȰO~x~󬲔i @..?>'@ ô.RxCvdZ >~A`3B}fœOGGGk4Z16E6l H 涒_ۣGC0 Gv\cǾ+ %%%.Oʬ$u//ϫB>sB`a+AE ;=IM B`tȵZVݕ.`U򸯐`Djj*2VZZ Ϟ=B̘1C͵׮]4hPRRR]]>^~3T/\p:ٳ2+v˗/2ds[⋓&Muɓ'===?3!9-9v򜜜':;;+_rѣf͚BLKn7nlC ȮT!0Hi&91]' - -R7}qn+>>r @lyRʮi" v B`Gt ( γR>BBE=])O \~},6G aZYߌi۞0 z?Op&1 R_{A+or &4*b`; %$@/JBB-}CTpAbշI!@B=Pj[Jp!b @. @l(5B -F!@kfʔ)B XwލC?!bփ W&C!bփ W!@z0Ƞ?Q߸qCOOπ>Be+aaa/++/̙#Ӫ|8ydmm+uu>Be(W^upp>LfuI&\}@/ AffQ̙3 =&{֯_os= a , Ph4e333mic!@z"<ȑ#GsUUUB&88cԩYYYB (Mo[6Thmlbم0 ֬qs^-<<<~ӟD .IDATIA}4oӕ>Beփ8$ ur/,wp- GE$Jq aG tQ>S6=}≟ 9v)0;dr? 6  -C|:$iwww0\B34CdN! B=AB$ZI̒Yӕ"W333Mi^?s}S!B C{3\ !!@!ؿ2`Djjjff$ru^oYG֑ߒ%5.[[=/^iV 688Ṋ6w\X(I,3GB"tqqSuua%=+_B^^"&jhh3fX˗?#׮]4hPRRR]]n߾]FÝXXmÆ +=?ûX& ?B ,\p:NRٳgeI%K_~ՙ3gr"EEEIKK|L۷ť^VZvmKKK!sŋ_x-ywttȐ=>}Wͣ;'|2rHXc̕_~nj?CaرǏ#kllq T\6y;wB`r?[nwuu󜜜'JW2Pzч {m6zYFͣvbn5ERRR\W\l23~/0;;{XN@B[ZosNM]Շ/16==ӯ.6D}qq+Z-M~ BqqɣUeɱY$th}?% +@h7+6Z;$DqK__Z'q㻵TM :֒'a8M :0 aPN@D: ~A@t~-7t=Z&h&h&h&| &&&&@TTT888TUUuq]&114  5#6N޽۪ ++k„ ;4 Dn1mڴ{:tرc}suw'1bĝ;w|APKKK#yE b2|XggGlܸqҥ}߷Bϯ\؂ SLY~}XXWZ;lذȢ"er4iҎ;7f%%%FGPBBBdILL(B|r9ҋ.{5e?6mRjBxx-[=~d`0~/PصkԩS-Ba… ,tϞ=+MMM\_,YrWΜ9SM/ (** WOZZZ\\˗ez߾}...rTTڵk[ZZ:;w.^ /oȃcFFӾj5܉>䓑#G2C`/c=f\u& cǎ=~8J5:'~f7ju~0iߊѽvf]օ`f!16UICk{w yh. $B!@YZCsL2KC@[L\-_{2|:u-vU@s?Kp%Np~WXk-}jUYRB0Т8*O]-: `@Fe<^JtP: z= $:`BpaB=^;[ΝN̑?bĈ;wXmJ[??4 B= (6J^Z&/^|>I7n\t5 ?rJ B= A72e<<<}z~t:KNNNcccnn<[!PIOO=_pA]"g ƫI6ݽ{5Ρ"=zۛ@w!ؼyaJ+++Sf%yyy?^2,T,?qhn? e9t*.]J*ܗ(iZ5\X&pHM~@ AHFܿ2`Djjjffd2u^o9#ۏ#%KZlYAAAz.^(ZV|܉Ν#k+I,C/+.-B#=F2b||2]\\T]]mTIJ6WRdDsQf9<77443, yk׮ 4())Nfo߮~n mذpP+=9BTb m^[Zo{/… ,X$W={Vfd Z.鿡ϕ/5~㕛o`{P:͈V] *zۤ4Uȵ**]G+ Ȉ}uP4y!<'F~wE%˫KDbTJ*\}+ENw߸K;{!T&orI$B&?:YWJ9?^;9; 1׳> h*|0qy|ˀ :M~ BCIeɼ.~%aЛ!&(=0@lh-M!-M!-M!-M!&&)Slۊ *e6###00h4D+1MfPF|/䫾n 4y@@t@AA!! 3hB-}]C84$yTD=m&ucߊI4K!ی0`bapq߳'`P!Mfm_i`[rkhhh_BB :z#vD P4yF D P4yF D P4yF D P4y [ #;7==]sJ!0y ʃxCCΘ1υLn؃RoذpPMTb b 1!#!`ب .\`N zYmjjK,~իWgΜŋGGGcL'---..NrL۷ť^VZvmKKK_ ۰OFṙD;v:B= v!8:v7:66qƹ~NiiF17sdgg?^ }n?:qh}gɖs=~| KNؐkZnc@=’F>u~.Π<>x?o^;|g E'hJ*_~r ]G+ z&Bt MU5D;D%˫Kb]B0WW'dV*?{4?{7۔Fg_+JL2KCkOZy饗ٓ-]JYr ):qhH{K{&-!z-_|qs^Y?Aڻ5??[!{L\-U!/kxzg*@Cp=SjDH=/~5Dl`}N@ރ`ug γ "}}O Kp /7oa@"l={H4|_#KZ@~~!;;[r?G_#KDt:@m-roBx!T !;H BB`B@B=PjW!b @. @l(5Be-Flz=L߳m+**ٌ@-FILLDB=Pjdݻwr>9VrrVWVVք f6m޽{وdzGqC/-- !@lXV^}?WBqƥKZs ?ʕ+9raaa_ja"##esҤI;vPS1cdPRR"kjjd~$yȓ[HHH^^,Q;OM rΒ̙#3a„Ǐc| v[l:v5uTC?$:uUf5MaaammudyhhE*++\2}Nt:خtIgΜihhؼya۰n8X P8z!-"LRa6*++Sf%xyy?^,L,?qdfn?e9XCd,2}MYps2aJm,V=ʛ5]-!8l $x̞끫owB~e:++LIB&zr23YG~KlٲCAYv~< {PsvnO?2.ұo?d6o=}>t=͞@=Pja!PsdqqSuuuu:<ݦ_RB^^GEw 3fs!0y 6l0Thuxޠ+Vt~PS!Cl;>j?@=Pj B ,\p:NٳgeI/YW^9s/^- (<<\~$}\\\e娨k׶; [O>dȑ [a cǎUGM :G=) J /-kƎ7wJKK5ymnnn'O޹sLy֭@*U999'Ntvv[܁mR+JII:?~27N ?2}cMew~*p2 ~°2{- -}V' |#3jakcs;|ukg6m~i+o^Ћ$|]}&h,;Os]{Aﱉ:T;j8^;g!(hJuIͧ:x?o^;|Sԑ6pl ǒ;,W <4W~Wepa[B`A (/Ҟ={+ʓy2(z,\rx!!o믿j󩫁 ! %nw~.,Np |oltr2t?#NGRv+tKm%$|y_IKeF-zFcwOcg?uϞ=ZVl ??_h %PGs?Kp%Npe /dev/null || [ -n "${DOCKER_FILE_CHANGES}" ]; then docker build --rm -f docker_env/Dockerfile -t "${DOCKER_IMAGE_NAME}" . if ! docker run --rm --privileged "${DOCKER_IMAGE_NAME}" "./buildenv_check.sh"; then echo "Something is wrong with the build environment, please check your Dockerfile." docker image rm "${DOCKER_IMAGE_NAME}" exit 1 fi fi DOCKER_WORK_DIR="${WORKDIR:-/build/charon}" PREFIX="/usr" run_in_docker() { echo "Running '${*}' in docker." docker run \ --rm \ --privileged \ -v "$(pwd):${DOCKER_WORK_DIR}" \ -v "$(pwd)/../:${DOCKER_WORK_DIR}/.." \ -e "PREFIX=${PREFIX}" \ -e "RELEASE_VERSION=${RELEASE_VERSION:-}" \ -w "${DOCKER_WORK_DIR}" \ "${DOCKER_IMAGE_NAME}" \ "${@}" } libCharon-4.13.0/mypy.ini000066400000000000000000000010401413347326300152000ustar00rootroot00000000000000[mypy] python_version = 3.4 disallow_untyped_calls = False disallow_untyped_defs = False disallow_incomplete_defs = False check_untyped_defs = False warn_incomplete_stub = True warn_redundant_casts = True warn_no_return = True warn_return_any = False disallow_subclassing_any = False disallow_any_unimported = False disallow_any_expr = False disallow_any_decorated = False disallow_any_explicit = False disallow_any_generics = False warn_unused_ignores = False ignore_missing_imports = True strict_optional = False no_implicit_optional = False libCharon-4.13.0/pycodestyle.ini000066400000000000000000000002521413347326300165520ustar00rootroot00000000000000[pycodestyle] select = E101, E111, E112, E113, E201, E202, E203, E211, E221, E222, E223, E224, E225, E226, E227, E228, E241, E242, E4, E7, E9, W1, W292, W6 ignore = E501 libCharon-4.13.0/pytest.ini000066400000000000000000000001721413347326300155370ustar00rootroot00000000000000[pytest] testpaths = tests python_files = Test*.py python_classes = Test timeout = 30 log_cli = 1 log_cli_level = WARNING libCharon-4.13.0/release.sh000077500000000000000000000054111413347326300154660ustar00rootroot00000000000000#!/bin/bash # Copyright (C) 2019 Ultimaker B.V. # Copyright (C) 2019 Raymond Siudak # # SPDX-License-Identifier: LGPL-3.0+ set -eu set -o pipefail usage() { echo "Usage: ${0} [OPTIONS] " echo "Triggers the release of this package to the CloudSmith package storage, given the" echo "release version passed as argument to the script, e.g. 6.0.1 or 6.0.1-dev." echo "" echo "This script wil create a tag and push that to origin, this triggers the CI job to release" echo "to CloudSmith. The CI release job will differentiate between pushing to official release" echo "storage or development release storage, pushing to development release storage is " echo "triggerred by adding the '-dev' postfix to the release version e.g. 6.2.0-dev." echo "" echo " -h Print usage" } is_tag_existing_locally() { if git rev-parse "${TAG}" > /dev/null 2>&1; then echo "WARNING: Local Git tag '${TAG}' already exists." return 0 fi return 1 } is_tag_on_github() { if ! git ls-remote origin ":refs/tags/${TAG}"; then echo "WARNING: GitHub tag '${TAG}' already exists." return 0 fi return 1 } trigger_release() { if is_tag_existing_locally; then if ! git tag -d "${TAG}"; then echo "Error: failed to clear local tag'${TAG}'." exit 1 fi fi if ! git tag "${TAG}"; then echo "Error: failed to tag with '${TAG}'." exit 1 fi if ! is_tag_on_github; then if ! git push origin "${TAG}"; then echo "Error: failed to push tag: '${TAG}'." exit 1 fi return 0 fi return 1 } while getopts ":h" options; do case "${options}" in h) usage exit 0 ;; :) echo "Option -${OPTARG} requires an argument." exit 1 ;; ?) echo "Invalid option: -${OPTARG}" exit 1 ;; esac done shift "$((OPTIND - 1))" if [ "${#}" -ne 1 ]; then echo "Too much or too little arguments, arguments should be exactly one: ." usage exit 1 fi RELEASE_VERSION="${1}" TAG="$(git rev-parse --abbrev-ref HEAD)-v${RELEASE_VERSION}" if echo "${RELEASE_VERSION}" | grep -E '^[0-9]{1,3}+\.[0-9]{1,3}+\.[0-9]{1,3}+(-dev)?$'; then if is_tag_on_github; then echo "Error: Cannot continue, tag is already on GitHub." exit 1 fi if trigger_release; then echo "Successfully triggered release '${RELEASE_VERSION}', follow the build at: http://34.90.73.76/dashboard." exit 0 fi echo "Something went wrong triggering the release, please check the warnings and correct manually." fi echo "Invalid release version: '${RELEASE_VERSION}' given." usage exit 1 libCharon-4.13.0/requirements-testing.txt000066400000000000000000000000171413347326300204430ustar00rootroot00000000000000pytest coveragelibCharon-4.13.0/run_all_tests.sh000077500000000000000000000003651413347326300167270ustar00rootroot00000000000000#!/bin/sh set -eu # Run the make_docker.sh script here, within the context of the run_all_tests.sh script . ./make_docker.sh git fetch for test in ci/*.sh ; do run_in_docker "${test}" || echo "Failed!" done echo "Testing done!" exit 0 libCharon-4.13.0/run_complexity_analysis.sh000077500000000000000000000001541413347326300210310ustar00rootroot00000000000000#!/bin/sh set -eu . ./make_docker.sh run_in_docker "ci/complexity_analysis.sh" || echo "Failed!" exit 0 libCharon-4.13.0/run_dead_code_analysis.sh000077500000000000000000000001531413347326300205220ustar00rootroot00000000000000#!/bin/sh set -eu . ./make_docker.sh run_in_docker "ci/dead_code_analysis.sh" || echo "Failed!" exit 0 libCharon-4.13.0/run_mypy.sh000077500000000000000000000001501413347326300157230ustar00rootroot00000000000000#!/bin/sh set -eu . ./make_docker.sh git fetch run_in_docker "ci/mypy.sh" || echo "Failed!" exit 0 libCharon-4.13.0/run_pytest.sh000077500000000000000000000001371413347326300162620ustar00rootroot00000000000000#!/bin/sh set -eu . ./make_docker.sh run_in_docker "ci/pytest.sh" || echo "Failed!" exit 0 libCharon-4.13.0/run_shellcheck.sh000077500000000000000000000015721413347326300170430ustar00rootroot00000000000000#!/bin/sh # # Copyright (C) 2019 Ultimaker B.V. # # SPDX-License-Identifier: LGPL-3.0+ # This script is mandatory in a repository, to make sure the shell scripts are correct and of good quality. set -eu SHELLCHECK_FAILURE="false" # Add your scripts or search paths here SHELLCHECK_PATHS=" \ *.sh \ ./docker_env/*.sh \ ci/*.sh " # shellcheck disable=SC2086 SCRIPTS="$(find ${SHELLCHECK_PATHS} -name '*.sh')" for script in ${SCRIPTS}; do if [ ! -r "${script}" ]; then echo_line echo "WARNING: skipping shellcheck for '${script}'." echo_line continue fi echo "Running shellcheck on '${script}'" shellcheck -x -C -f tty "${script}" || SHELLCHECK_FAILURE="true" done if [ "${SHELLCHECK_FAILURE}" = "true" ]; then echo "WARNING: One or more scripts did not pass shellcheck." exit 1 fi echo "All scripts passed shellcheck." exit 0 libCharon-4.13.0/run_style_analysis.sh000077500000000000000000000001621413347326300177730ustar00rootroot00000000000000#!/bin/sh set -eu . ./make_docker.sh git fetch run_in_docker "ci/style_analysis.sh" || echo "Failed!" exit 0 libCharon-4.13.0/service/000077500000000000000000000000001413347326300151465ustar00rootroot00000000000000libCharon-4.13.0/service/charon.service000066400000000000000000000005721413347326300200060ustar00rootroot00000000000000[Unit] Description=Charon File Metadata service Requires=rc-local.service After=rc-local.service [Service] Environment=CHARON_USE_SESSION_BUS=0 Environment='PYTHONPATH=$PYTHONPATH:/opt/pyqt' ExecStart=/usr/bin/python3 /usr/lib/python3/dist-packages/Charon/Service/main.py BusName=nl.ultimaker.charon User=ultimaker Type=simple Restart=always [Install] WantedBy=griffin.target libCharon-4.13.0/service/nl.ultimaker.charon.conf000066400000000000000000000006161413347326300216760ustar00rootroot00000000000000 libCharon-4.13.0/service/postinst000077500000000000000000000006411413347326300167600ustar00rootroot00000000000000#!/bin/sh # DBus currently does not load config files from /usr but the # system-supplied config files really should be installed there. # So symlink things to /etc instead. ln -s /usr/share/dbus-1/system.d/nl.ultimaker.charon.conf /etc/dbus-1/system.d/ # Then, make sure DBus knows the policy file exists. systemctl reload dbus # Finally, enable the service systemctl daemon-reload systemctl enable charon.service libCharon-4.13.0/setup.py000066400000000000000000000007161413347326300152240ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. from distutils.core import setup setup( name = "Charon", version = "1.0", description = "Library to read and write file packages.", author = "Ultimaker", author_email = "plugins@ultimaker.com", url = "https://github.com/Ultimaker/libCharon", packages = ["Charon", "Charon.Client", "Charon.Service", "Charon.filetypes"] ) libCharon-4.13.0/tests/000077500000000000000000000000001413347326300146505ustar00rootroot00000000000000libCharon-4.13.0/tests/__init__.py000066400000000000000000000001451413347326300167610ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. libCharon-4.13.0/tests/filetypes/000077500000000000000000000000001413347326300166545ustar00rootroot00000000000000libCharon-4.13.0/tests/filetypes/TestGCodeFile.py000066400000000000000000000234001413347326300216460ustar00rootroot00000000000000import io import unittest from Charon.filetypes.GCodeFile import GCodeFile, InvalidHeaderException class TestGcodeFile(unittest.TestCase): __minimal_griffin_header = ";START_OF_HEADER\n" \ ";FLAVOR:Griffin\n" \ ";TARGET_MACHINE.NAME:target.foobar\n" \ ";GENERATOR.NAME:generator_foo\n" \ ";GENERATOR.VERSION: generator_version_foo\n" \ ";GENERATOR.BUILD_DATE: generator_build_foo\n" \ ";BUILD_PLATE.INITIAL_TEMPERATURE:30\n" \ ";PRINT.SIZE.MIN.X:1\n" \ ";PRINT.SIZE.MIN.Y:2\n" \ ";PRINT.SIZE.MIN.Z:3\n" \ ";PRINT.SIZE.MAX.X:1\n" \ ";PRINT.SIZE.MAX.Y:2\n" \ ";PRINT.SIZE.MAX.Z:3\n" \ ";HEADER_VERSION:0.1\n" \ ";TIME:11\n" \ ";EXTRUDER_TRAIN.1.NOZZLE.DIAMETER:1.5\n" \ ";EXTRUDER_TRAIN.1.MATERIAL.VOLUME_USED:1.5\n" \ ";EXTRUDER_TRAIN.1.INITIAL_TEMPERATURE:666\n" \ "{}\n" \ ";END_OF_HEADER" def _print(self, d, prefix=""): for k, v in d.items(): if type(v) is dict: self._print(v, prefix="{}.{}".format(prefix, k) if prefix else "{}".format(k)) else: if prefix: print("{}.{}: {}".format(prefix, k, v)) else: print("{}: {}".format(k, v)) def testParseGenericParameter_HappyTrail(self) -> None: gcode = self.__minimal_griffin_header.format(";A.B.C:5") gcode_stream = io.BytesIO(str.encode(gcode)) metadata = GCodeFile.parseHeader(gcode_stream) self._print(metadata) # print if any assert fails self.assertEqual(metadata["a"]["b"]["c"], 5) self.assertEqual(metadata["generator"]["name"], "generator_foo") self.assertEqual(metadata["build_plate"]["initial_temperature"], 30) self.assertEqual(metadata["extruders"][1]["nozzle"]["diameter"], 1.5) self.assertEqual(metadata["print"]["time"], 11) self.assertEqual(metadata["time"], 11) # This was the behavior of the old code. def testParseHeader_MissingFlavor(self) -> None: gcode = ";START_OF_HEADER\n" \ ";TARGET_MACHINE.NAME:target.foobar\n" \ ";GENERATOR.NAME:generator_foo\n" \ ";GENERATOR.VERSION: generator_version_foo\n" \ ";GENERATOR.BUILD_DATE: generator_build_foo\n" \ ";BUILD_PLATE.INITIAL_TEMPERATURE:30\n" \ ";PRINT.SIZE.MIN.X:1\n" \ ";PRINT.SIZE.MIN.Y:2\n" \ ";PRINT.SIZE.MIN.Z:3\n" \ ";PRINT.SIZE.MAX.X:1\n" \ ";PRINT.SIZE.MAX.Y:2\n" \ ";PRINT.SIZE.MAX.Z:3\n" \ ";HEADER_VERSION:0.1\n" \ ";TIME:11\n" self.__parseWithInvalidHeaderException(gcode, "Flavor") def testParseHeader_MissingHeaderVersion(self) -> None: gcode = ";START_OF_HEADER\n" \ ";FLAVOR:Griffin\n" \ ";TARGET_MACHINE.NAME:target.foobar\n" \ ";GENERATOR.NAME:generator_foo\n" \ ";GENERATOR.VERSION: generator_version_foo\n" \ ";GENERATOR.BUILD_DATE: generator_build_foo\n" \ ";BUILD_PLATE.INITIAL_TEMPERATURE:30\n" \ ";PRINT.SIZE.MIN.X:1\n" \ ";PRINT.SIZE.MIN.Y:2\n" \ ";PRINT.SIZE.MIN.Z:3\n" \ ";PRINT.SIZE.MAX.X:1\n" \ ";PRINT.SIZE.MAX.Y:2\n" \ ";PRINT.SIZE.MAX.Z:3\n" \ ";TIME:11\n" self.__parseWithInvalidHeaderException(gcode, "version") def testParseHeader_MissingTargetMachine(self) -> None: gcode = ";START_OF_HEADER\n" \ ";FLAVOR:Griffin\n" \ ";GENERATOR.NAME:generator_foo\n" \ ";GENERATOR.VERSION: generator_version_foo\n" \ ";GENERATOR.BUILD_DATE: generator_build_foo\n" \ ";BUILD_PLATE.INITIAL_TEMPERATURE:30\n" \ ";PRINT.SIZE.MIN.X:1\n" \ ";PRINT.SIZE.MIN.Y:2\n" \ ";PRINT.SIZE.MIN.Z:3\n" \ ";PRINT.SIZE.MAX.X:1\n" \ ";PRINT.SIZE.MAX.Y:2\n" \ ";PRINT.SIZE.MAX.Z:3\n" \ ";HEADER_VERSION:0.1\n" \ ";TIME:11\n" self.__parseWithInvalidHeaderException(gcode, "TARGET_MACHINE") def testParseHeader_MissingGeneratorName(self) -> None: gcode = ";START_OF_HEADER\n" \ ";FLAVOR:Griffin\n" \ ";TARGET_MACHINE.NAME:target.foobar\n" \ ";GENERATOR.VERSION: generator_version_foo\n" \ ";GENERATOR.BUILD_DATE: generator_build_foo\n" \ ";BUILD_PLATE.INITIAL_TEMPERATURE:30\n" \ ";PRINT.SIZE.MIN.X:1\n" \ ";PRINT.SIZE.MIN.Y:2\n" \ ";PRINT.SIZE.MIN.Z:3\n" \ ";PRINT.SIZE.MAX.X:1\n" \ ";PRINT.SIZE.MAX.Y:2\n" \ ";PRINT.SIZE.MAX.Z:3\n" \ ";HEADER_VERSION:0.1\n" \ ";TIME:11\n" self.__parseWithInvalidHeaderException(gcode, "GENERATOR.NAME") def testParseHeader_MissingGeneratorVersion(self) -> None: gcode = ";START_OF_HEADER\n" \ ";FLAVOR:Griffin\n" \ ";TARGET_MACHINE.NAME:target.foobar\n" \ ";GENERATOR.NAME:generator_foo\n" \ ";GENERATOR.BUILD_DATE: generator_build_foo\n" \ ";BUILD_PLATE.INITIAL_TEMPERATURE:30\n" \ ";PRINT.SIZE.MIN.X:1\n" \ ";PRINT.SIZE.MIN.Y:2\n" \ ";PRINT.SIZE.MIN.Z:3\n" \ ";PRINT.SIZE.MAX.X:1\n" \ ";PRINT.SIZE.MAX.Y:2\n" \ ";PRINT.SIZE.MAX.Z:3\n" \ ";HEADER_VERSION:0.1\n" \ ";TIME:11\n" self.__parseWithInvalidHeaderException(gcode, "GENERATOR.VERSION") def testParseHeader_MissingGeneratorBuildDate(self) -> None: gcode = ";START_OF_HEADER\n" \ ";FLAVOR:Griffin\n" \ ";TARGET_MACHINE.NAME:target.foobar\n" \ ";GENERATOR.NAME:generator_foo\n" \ ";GENERATOR.VERSION: generator_version_foo\n" \ ";BUILD_PLATE.INITIAL_TEMPERATURE:30\n" \ ";PRINT.SIZE.MIN.X:1\n" \ ";PRINT.SIZE.MIN.Y:2\n" \ ";PRINT.SIZE.MIN.Z:3\n" \ ";PRINT.SIZE.MAX.X:1\n" \ ";PRINT.SIZE.MAX.Y:2\n" \ ";PRINT.SIZE.MAX.Z:3\n" \ ";HEADER_VERSION:0.1\n" \ ";TIME:11\n" self.__parseWithInvalidHeaderException(gcode, "GENERATOR.BUILD_DATE") def testParseHeader_MissingInitialBuildPlateTemp(self) -> None: gcode = ";START_OF_HEADER\n" \ ";FLAVOR:Griffin\n" \ ";TARGET_MACHINE.NAME:target.foobar\n" \ ";GENERATOR.NAME:generator_foo\n" \ ";GENERATOR.VERSION: generator_version_foo\n" \ ";GENERATOR.BUILD_DATE: generator_build_foo\n" \ ";PRINT.SIZE.MIN.X:1\n" \ ";PRINT.SIZE.MIN.Y:2\n" \ ";PRINT.SIZE.MIN.Z:3\n" \ ";PRINT.SIZE.MAX.X:1\n" \ ";PRINT.SIZE.MAX.Y:2\n" \ ";PRINT.SIZE.MAX.Z:3\n" \ ";HEADER_VERSION:0.1\n" \ ";TIME:11\n" self.__parseWithInvalidHeaderException(gcode, "BUILD_PLATE.INITIAL_TEMPERATURE") def testParseHeader_MissingMinSizeX(self) -> None: gcode = ";START_OF_HEADER\n" \ ";FLAVOR:Griffin\n" \ ";TARGET_MACHINE.NAME:target.foobar\n" \ ";GENERATOR.NAME:generator_foo\n" \ ";GENERATOR.VERSION: generator_version_foo\n" \ ";GENERATOR.BUILD_DATE: generator_build_foo\n" \ ";BUILD_PLATE.INITIAL_TEMPERATURE:30\n" \ ";PRINT.SIZE.MIN.Y:2\n" \ ";PRINT.SIZE.MIN.Z:3\n" \ ";PRINT.SIZE.MAX.X:1\n" \ ";PRINT.SIZE.MAX.Y:2\n" \ ";PRINT.SIZE.MAX.Z:3\n" \ ";HEADER_VERSION:0.1\n" \ ";TIME:11\n" self.__parseWithInvalidHeaderException(gcode, "PRINT.SIZE.MIN") def testParseHeader_MissingMaxSizeZ(self) -> None: gcode = ";START_OF_HEADER\n" \ ";FLAVOR:Griffin\n" \ ";TARGET_MACHINE.NAME:target.foobar\n" \ ";GENERATOR.NAME:generator_foo\n" \ ";GENERATOR.VERSION: generator_version_foo\n" \ ";GENERATOR.BUILD_DATE: generator_build_foo\n" \ ";BUILD_PLATE.INITIAL_TEMPERATURE:30\n" \ ";PRINT.SIZE.MIN.X:1\n" \ ";PRINT.SIZE.MIN.Y:2\n" \ ";PRINT.SIZE.MIN.Z:3\n" \ ";PRINT.SIZE.MAX.X:1\n" \ ";PRINT.SIZE.MAX.Y:2\n" \ ";HEADER_VERSION:0.1\n" \ ";TIME:11\n" self.__parseWithInvalidHeaderException(gcode, "PRINT.SIZE.MAX") def testParseHeader_MissingPrintTime(self) -> None: gcode = ";START_OF_HEADER\n" \ ";FLAVOR:Griffin\n" \ ";TARGET_MACHINE.NAME:target.foobar\n" \ ";GENERATOR.NAME:generator_foo\n" \ ";GENERATOR.VERSION: generator_version_foo\n" \ ";GENERATOR.BUILD_DATE: generator_build_foo\n" \ ";BUILD_PLATE.INITIAL_TEMPERATURE:30\n" \ ";PRINT.SIZE.MIN.X:1\n" \ ";PRINT.SIZE.MIN.Y:2\n" \ ";PRINT.SIZE.MIN.Z:3\n" \ ";PRINT.SIZE.MAX.X:1\n" \ ";PRINT.SIZE.MAX.Y:2\n" \ ";PRINT.SIZE.MAX.Z:3\n" \ ";HEADER_VERSION:0.1\n" self.__parseWithInvalidHeaderException(gcode, "TIME") self.__parseWithInvalidHeaderException(gcode, "PRINT.TIME") def __parseWithInvalidHeaderException(self, gcode, text) -> None: gcode_stream = io.BytesIO(str.encode(gcode)) with self.assertRaises(InvalidHeaderException) as cm: metadata = GCodeFile.parseHeader(gcode_stream) self.assertTrue(text in str(cm.exception)) def testParseGenericParameter_NoValue(self) -> None: gcode = self.__minimal_griffin_header.format(";A.B.C:") gcode_stream = io.BytesIO(str.encode(gcode)) metadata = GCodeFile.parseHeader(gcode_stream) self.assertEqual(metadata["a"]["b"]["c"], '') libCharon-4.13.0/tests/filetypes/TestGCodeFormat.py000066400000000000000000000013111413347326300222140ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # Charon is released under the terms of the LGPLv3 or higher. import os from Charon.VirtualFile import VirtualFile def test_GCodeReader(): f = VirtualFile() f.open(os.path.join(os.path.dirname(__file__), "resources", "um3.gcode")) assert f.getData("/metadata")["/metadata/toolpath/default/flavor"] == "Griffin" assert b"M104" in f.getStream("/toolpath").read() f.close() def test_GCodeGzReader(): f = VirtualFile() f.open(os.path.join(os.path.dirname(__file__), "resources", "um3.gcode.gz")) assert f.getData("/metadata")["/metadata/toolpath/default/flavor"] == "Griffin" assert b"M104" in f.getStream("/toolpath").read() f.close() libCharon-4.13.0/tests/filetypes/TestOpenPackagingConvention.py000066400000000000000000000264031413347326300246440ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # Charon is released under the terms of the LGPLv3 or higher. import io #To create fake streams to write to and read from. import os #To find the resources with test packages. import pytest #This module contains unit tests. import zipfile #To inspect the contents of the zip archives. import xml.etree.ElementTree as ET #To inspect the contents of the OPC-spec files in the archives. from collections import OrderedDict from typing import List, Generator from Charon.filetypes.OpenPackagingConvention import OpenPackagingConvention, OPCError # The class we're testing. from Charon.OpenMode import OpenMode #To open archives. ## Returns an empty package that you can read from. # # The package has no resources at all, so reading from it will not find # anything. @pytest.fixture() def empty_read_opc() -> Generator[OpenPackagingConvention, None, None]: result = OpenPackagingConvention() result.openStream(open(os.path.join(os.path.dirname(__file__), "resources", "empty.opc"), "rb")) yield result result.close() ## Returns a package that has a single file in it. # # The file is called "hello.txt" and contains the text "Hello world!" encoded # in UTF-8. @pytest.fixture() def single_resource_read_opc() -> Generator[OpenPackagingConvention, None, None]: result = OpenPackagingConvention() result.openStream(open(os.path.join(os.path.dirname(__file__), "resources", "hello.opc"), "rb")) yield result result.close() ## Returns an empty package that you can write to. # # Note that you can't really test the output of the write since you don't have # the stream it writes to. @pytest.fixture() def empty_write_opc() -> Generator[OpenPackagingConvention, None, None]: result = OpenPackagingConvention() result.openStream(io.BytesIO(), "application/x-opc", OpenMode.WriteOnly) yield result result.close() #### Now follow the actual tests. #### ## Tests whether an empty file is recognised as empty. def test_listPathsEmpty(empty_read_opc: OpenPackagingConvention): assert len(empty_read_opc.listPaths()) == 0 ## Tests getting write streams of various resources that may or may not exist. # # Every test will write some arbitrary data to it to see that that also works. @pytest.mark.parametrize("virtual_path", ["/dir/file", "/file", "dir/file", "file", "/Metadata"]) #Some extra tests without initial slash to test robustness. def test_getWriteStream(empty_write_opc: OpenPackagingConvention, virtual_path: str): stream = empty_write_opc.getStream(virtual_path) stream.write(b"The test is successful.") ## Tests not allowing to open relationship file directly to prevent mistakes. @pytest.mark.parametrize("virtual_path", ["/_rels/.rels"]) def test_getWriteStream_forbidOnRels(empty_write_opc: OpenPackagingConvention, virtual_path: str): with pytest.raises(OPCError): empty_write_opc.getStream(virtual_path) ## Tests writing data to an archive, then reading it back. @pytest.mark.parametrize("virtual_path", ["/dir/file", "/file", "/Metadata"]) #Don't try to read .rels back. That won't work. def test_cycleSetDataGetData(virtual_path: str): test_data = b"Let's see if we can read this data back." stream = io.BytesIO() package = OpenPackagingConvention() package.openStream(stream, mode = OpenMode.WriteOnly) package.setData({virtual_path: test_data}) package.close() stream.seek(0) package = OpenPackagingConvention() package.openStream(stream) result = package.getData(virtual_path) assert len(result) == 1 #This data must be the only data we've found. assert virtual_path in result #The path must be in the dictionary. assert result[virtual_path] == test_data #The data itself is still correct. @pytest.mark.parametrize("virtual_path, path_list", [ ("/foo/materials", ["/foo/materials", "/[Content_Types].xml", "/_rels/.rels"]), ("/materials", ["/files/materials", "/[Content_Types].xml", "/_rels/.rels"]) ]) def test_aliases_replacement(virtual_path: str, path_list: List[str]): test_data = b"Let's see if we can read this data back." stream = io.BytesIO() package = OpenPackagingConvention() package._aliases = OrderedDict([ (r"/materials", "/files/materials") ]) package.openStream(stream, mode = OpenMode.WriteOnly) package.setData({virtual_path: test_data}) package.close() stream.seek(0) package = OpenPackagingConvention() package.openStream(stream) result = package.listPaths() assert result == path_list ## Tests writing data via a stream to an archive, then reading it back via a # stream. @pytest.mark.parametrize("virtual_path", ["/dir/file", "/file", "/Metadata"]) def test_cycleStreamWriteRead(virtual_path: str): test_data = b"Softly does the river flow, flow, flow." stream = io.BytesIO() package = OpenPackagingConvention() package.openStream(stream, mode = OpenMode.WriteOnly) resource = package.getStream(virtual_path) resource.write(test_data) package.close() stream.seek(0) package = OpenPackagingConvention() package.openStream(stream) resource = package.getStream(virtual_path) result = resource.read() assert result == test_data ## Tests setting metadata in an archive, then reading that metadata back. @pytest.mark.parametrize("virtual_path", ["/Metadata/some/global/setting", "/hello.txt/test", "/also/global/entry"]) def test_cycleSetMetadataGetMetadata(virtual_path: str): test_data = "Hasta la vista, baby." stream = io.BytesIO() package = OpenPackagingConvention() package.openStream(stream, mode = OpenMode.WriteOnly) package.setData({"/hello.txt": b"Hello world!"}) #Add a file to attach non-global metadata to. package.setMetadata({virtual_path: test_data}) package.close() stream.seek(0) package = OpenPackagingConvention() package.openStream(stream) result = package.getMetadata(virtual_path) prefixed_virtual_path = "/metadata{}".format(virtual_path) assert len(result) == 1 #Only one metadata entry was set. assert prefixed_virtual_path in result #And it was the correct entry. assert result[prefixed_virtual_path] == test_data #With the correct value. ## Tests toByteArray with its parameters. # # This doesn't test if the bytes are correct, because that is the task of the # zipfile module. We merely test that it gets some bytes array and that the # offset and size parameters work. def test_toByteArray(single_resource_read_opc): original = single_resource_read_opc.toByteArray() original_length = len(original) #Even empty zip archives are already 22 bytes, so offsets and sizes of less than that should be okay. result = single_resource_read_opc.toByteArray(offset = 10) assert len(result) == original_length - 10 #The first 10 bytes have fallen off. result = single_resource_read_opc.toByteArray(count = 8) assert len(result) == 8 #Limited to size 8. result = single_resource_read_opc.toByteArray(offset = 10, count = 8) assert len(result) == 8 #Still limited by the size, even though there is an offset. result = single_resource_read_opc.toByteArray(count = 999999) #This is a small file, definitely smaller than 1MiB. assert len(result) == original_length #Should be limited to the actual file length. ## Tests toByteArray when loading from a stream. def test_toByteArrayStream(): stream = io.BytesIO() package = OpenPackagingConvention() package.openStream(stream, mode = OpenMode.WriteOnly) package.setData({"/hello.txt": b"Hello world!"}) #Add some arbitrary data so that the file size is not trivial regardless of what format is used. package.close() stream.seek(0) package = OpenPackagingConvention() package.openStream(stream) result = package.toByteArray() assert len(result) > 0 #There must be some data in it. ## Tests whether a content type gets added and that it gets added in the # correct location. def test_addContentType(): stream = io.BytesIO() package = OpenPackagingConvention() package.openStream(stream, mode = OpenMode.WriteOnly) package.addContentType("lol", "audio/x-laughing") package.close() stream.seek(0) #This time, open as .zip to just inspect the file contents. archive = zipfile.ZipFile(stream) assert "/[Content_Types].xml" in archive.namelist() content_types = archive.open("/[Content_Types].xml").read() content_types_element = ET.fromstring(content_types) defaults = content_types_element.findall("{http://schemas.openxmlformats.org/package/2006/content-types}Default") assert len(defaults) == 2 #We only added one content type, but there must also be the .rels content type. for default in defaults: assert "Extension" in default.attrib assert "ContentType" in default.attrib assert default.attrib["Extension"] in ["lol", "rels"] if default.attrib["Extension"] == "lol": assert default.attrib["ContentType"] == "audio/x-laughing" elif default.attrib["Extension"] == "rels": assert default.attrib["ContentType"] == "application/vnd.openxmlformats-package.relationships+xml" ## Tests whether a relation gets added and that it gets saved in the correct # location. def test_addRelation(): stream = io.BytesIO() package = OpenPackagingConvention() package.openStream(stream, mode = OpenMode.WriteOnly) package.setData({"/whoo.txt": b"Boo", "/whoo.enhanced.txt": b"BOOOO!", "/whoo.enforced.txt": b"BOOOOOOOOOO!"}) #Need 3 files: One base and two that are related. package.addRelation("whoo.enhanced.txt", "An enhanced version of it.", "whoo.txt") package.addRelation("whoo.enforced.txt", "A greatly enhanced version of it.", "whoo.txt") package.close() stream.seek(0) #This time, open as .zip to just inspect the file contents. archive = zipfile.ZipFile(stream) assert "/_rels/whoo.txt.rels" in archive.namelist() #It must create a file specifically for whoo.txt relations = archive.open("/_rels/whoo.txt.rels").read() relations_element = ET.fromstring(relations) both_relations = relations_element.findall("{http://schemas.openxmlformats.org/package/2006/relationships}Relationship") assert len(both_relations) == 2 #We added two relations. for relation in both_relations: assert "Id" in relation.attrib assert "Target" in relation.attrib assert "Type" in relation.attrib assert relation.attrib["Target"] == "/whoo.enhanced.txt" or relation.attrib["Target"] == "/whoo.enforced.txt" if relation.attrib["Target"] == "/whoo.enhanced.txt": assert relation.attrib["Type"] == "An enhanced version of it." elif relation.attrib["Target"] == "/whoo.enforced.txt": assert relation.attrib["Type"] == "A greatly enhanced version of it." assert both_relations[0].attrib["Id"] != both_relations[1].attrib["Id"] #Id must be unique. ## Tests getting the size of a file. # # This is implemented knowing the contents of single_resource_read_opc. def test_getMetadataSize(single_resource_read_opc): metadata = single_resource_read_opc.getMetadata("/hello.txt/size") assert "/metadata/hello.txt/size" in metadata assert metadata["/metadata/hello.txt/size"] == len("Hello world!\n".encode("UTF-8")) #Compare with the length of the file's contents as encoded in UTF-8. libCharon-4.13.0/tests/filetypes/__init__.py000066400000000000000000000001451413347326300207650ustar00rootroot00000000000000# Copyright (c) 2018 Ultimaker B.V. # libCharon is released under the terms of the LGPLv3 or higher. libCharon-4.13.0/tests/filetypes/resources/000077500000000000000000000000001413347326300206665ustar00rootroot00000000000000libCharon-4.13.0/tests/filetypes/resources/empty.opc000066400000000000000000000000261413347326300225250ustar00rootroot00000000000000PKlibCharon-4.13.0/tests/filetypes/resources/hello.opc000066400000000000000000000003231413347326300224720ustar00rootroot00000000000000PKeAL hello.txtUT rZrZrZux HW(/IQPKA䩲 PKeALA䩲 hello.txtUT rZrZrZux PKWflibCharon-4.13.0/tests/filetypes/resources/um3.gcode000066400000000000000000000013071413347326300223760ustar00rootroot00000000000000;START_OF_HEADER ;HEADER_VERSION:0.1 ;FLAVOR:Griffin ;GENERATOR.NAME:Cura_SteamEngine ;GENERATOR.VERSION:2.7.0 ;GENERATOR.BUILD_DATE:2017-08-30 ;TARGET_MACHINE.NAME:Ultimaker 3 ;EXTRUDER_TRAIN.0.INITIAL_TEMPERATURE:205 ;EXTRUDER_TRAIN.0.MATERIAL.VOLUME_USED:782066 ;EXTRUDER_TRAIN.0.MATERIAL.GUID:0e01be8c-e425-4fb1-b4a3-b79f255f1db9 ;EXTRUDER_TRAIN.0.NOZZLE.DIAMETER:0.4 ;EXTRUDER_TRAIN.0.NOZZLE.NAME:AA 0.4 ;BUILD_PLATE.INITIAL_TEMPERATURE:60 ;PRINT.TIME:342521 ;PRINT.SIZE.MIN.X:9 ;PRINT.SIZE.MIN.Y:6 ;PRINT.SIZE.MIN.Z:0.27 ;PRINT.SIZE.MAX.X:198.325 ;PRINT.SIZE.MAX.Y:189.325 ;PRINT.SIZE.MAX.Z:149.97 ;END_OF_HEADER ;Generated with Cura_SteamEngine 2.7.0 M104 S200 G1 X10 Y10 F1000 libCharon-4.13.0/tests/filetypes/resources/um3.gcode.gz000066400000000000000000000006101413347326300230110ustar00rootroot00000000000000[um3.gcode}n0Em N)2uoƉ