diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..11c0007 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# Commits to skip in `git blame` (GitHub uses this file automatically) +# Locally: git config blame.ignoreRevsFile .git-blame-ignore-revs + +# style: format code with black +425a2ffa431b407684bea0214c20772f1ad36204 diff --git a/.github/workflows/build-deb-package.yml b/.github/workflows/build-deb-package.yml index 6c6551b..38cdd5f 100644 --- a/.github/workflows/build-deb-package.yml +++ b/.github/workflows/build-deb-package.yml @@ -34,7 +34,6 @@ jobs: python3 -m pip install --upgrade pip pip3 install setuptools pip3 install stdeb - pip3 install python-dateutil pip3 install wheel - name: Clean up previous builds run: rm -rf deb_dist/ || true diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index caf4ce6..6a2af7e 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -31,12 +31,15 @@ jobs: python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Check formatting with black + run: | + black --check . - name: Lint with flake8 run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + flake8 src examples tests --count --show-source --statistics + - name: Type check with pyright + run: | + pyright - name: Test with pytest run: | pytest diff --git a/.vscode/settings.json b/.vscode/settings.json index 6b9625a..def1bd8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,5 @@ "-p", "test_*.py" ], - "debugpy.debugJustMyCode": false, - "python.analysis.typeCheckingMode": "strict" + "debugpy.debugJustMyCode": false } \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index e6210e4..1bcf795 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ include LICENSE include README.md include setup.py +include src/dsf/py.typed reverse-include examples/*.py diff --git a/conftest.py b/conftest.py index 8ab6c3e..cd110c9 100644 --- a/conftest.py +++ b/conftest.py @@ -1 +1 @@ -# Used to help pytest discover tests \ No newline at end of file +# Used to help pytest discover tests diff --git a/examples/subscribe_object_model.py b/examples/subscribe_object_model.py index 113ed14..db59dc0 100755 --- a/examples/subscribe_object_model.py +++ b/examples/subscribe_object_model.py @@ -19,8 +19,9 @@ def on_status_changed(*, key: str, data: str, indices: Optional[tuple[int, ...]] print("Machine status changed to", data) -def on_heater_temperature_changed(*, key: str, data: float, indices: tuple[int, ...]) -> None: - # indices holds the list indexes matched by each ^ wildcard in the key +def on_heater_temperature_changed(*, key: str, data: float, indices: tuple[int, ...] | None) -> None: + # indices holds the list indexes matched by each ^ wildcard in the key (None if the key has no wildcard) + assert indices is not None print(f"Heater {indices[0]} is now at {data}C") @@ -37,7 +38,9 @@ def subscribe() -> None: print("Machine status is", object_model.state.status.value) # Register callbacks for the keys we are interested in, ^ matches any list index - unsubscribe_status: Callable[[], None] = subscribe_connection.subscribe_to_keys(["state.status"], on_status_changed) + unsubscribe_status: Callable[[], None] = subscribe_connection.subscribe_to_keys( + ["state.status"], on_status_changed + ) subscribe_connection.subscribe_to_keys(["heat.heaters.^.current"], on_heater_temperature_changed) for _ in range(20): diff --git a/pyproject.toml b/pyproject.toml index 374b58c..6555f68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,3 +4,13 @@ requires = [ "wheel" ] build-backend = "setuptools.build_meta" + +[tool.pyright] +include = ["src", "tests", "examples"] +# Examples import the package as `dsf`, tests import it as `src.dsf` +extraPaths = ["src"] +pythonVersion = "3.11" +typeCheckingMode = "strict" + +[tool.black] +line-length = 120 diff --git a/requirements.txt b/requirements.txt index dd1f3d8..e375668 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ wheel build twine bump2version -python-dateutil stdeb requests +pyright[nodejs]==1.1.414 +black==26.5.1 diff --git a/setup.cfg b/setup.cfg index 857db67..2429e3b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,6 +5,26 @@ license_files = LICENSE [flake8] max-line-length = 120 +per-file-ignores = + # Imports come after the definitions that submodules import back from the package (avoids circular imports) + src/dsf/__init__.py: E402 + src/dsf/connections/__init__.py: E402 + # Tests embed object model JSON payloads verbatim + tests/*: E501 + +[check-manifest] +# Development-only files that are not part of the source distribution +ignore = + .bumpversion.cfg + .git-blame-ignore-revs + .vscode/** + Makefile + conftest.py + docs/** + examples/** + requirements.txt + tests/** + tox.ini [sdist_dsc] # Force the version to use `~` before any prerelease identifiers diff --git a/setup.py b/setup.py index 123a96e..d0a56c2 100644 --- a/setup.py +++ b/setup.py @@ -13,11 +13,11 @@ url="https://github.com/Duet3D/dsf-python", author="Duet3D Ltd.", author_email="pkg@duet3d.com", + license="LGPL-3.0-or-later", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", - "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -25,12 +25,10 @@ "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3 :: Only", ], - install_requires=[ - 'python-dateutil', - ], keywords="Duet3D, DuetSoftwareFramework, DSF, dsf-python", package_dir={"": "src"}, packages=setuptools.find_packages(where="src"), + package_data={"dsf": ["py.typed"]}, python_requires=">=3.11, <4", extras_require={ "dev": [ diff --git a/src/dsf/__init__.py b/src/dsf/__init__.py index a6234ba..621823a 100644 --- a/src/dsf/__init__.py +++ b/src/dsf/__init__.py @@ -3,20 +3,23 @@ import json import os -# Default socket file path -SOCKET_FILE: str = "/run/dsf/dcs.sock" - -# Try to read socket file path from config -config_path = "/opt/dsf/conf/config.json" -if os.path.exists(config_path): - try: - with open(config_path, 'r') as f: - config = json.load(f) - socket_dir = config.get("SocketDirectory", "/run/dsf") - socket_file = config.get("SocketFile", "dcs.sock") - SOCKET_FILE = os.path.join(socket_dir, socket_file) - except (json.JSONDecodeError, IOError): - pass # Use default if config file is invalid or inaccessible + +def _read_socket_file(config_path: str = "/opt/dsf/conf/config.json") -> str: + """Read the socket file path from the DSF config, falling back to the default path""" + if os.path.exists(config_path): + try: + with open(config_path, "r") as f: + config = json.load(f) + socket_dir: str = config.get("SocketDirectory", "/run/dsf") + socket_file: str = config.get("SocketFile", "dcs.sock") + return os.path.join(socket_dir, socket_file) + except (json.JSONDecodeError, IOError): + pass # Use default if config file is invalid or inaccessible + return "/run/dsf/dcs.sock" + + +# Socket file path +SOCKET_FILE = _read_socket_file() # allowed connection per unix server DEFAULT_BACKLOG = 4 @@ -25,3 +28,5 @@ PROTOCOL_VERSION = 13 from . import commands, connections, http, object_model + +__all__ = ["SOCKET_FILE", "DEFAULT_BACKLOG", "PROTOCOL_VERSION", "commands", "connections", "http", "object_model"] diff --git a/src/dsf/commands/__init__.py b/src/dsf/commands/__init__.py index 66b8544..684121f 100644 --- a/src/dsf/commands/__init__.py +++ b/src/dsf/commands/__init__.py @@ -1,2 +1,33 @@ -from . import base_command, code, code_interception, code_channel, code_parameter, files, generic,\ - http_endpoints, model_subscription, object_model, packages, plugins, responses, user_sessions +from . import ( + base_command, + code, + code_interception, + code_channel, + code_parameter, + files, + generic, + http_endpoints, + model_subscription, + object_model, + packages, + plugins, + responses, + user_sessions, +) + +__all__ = [ + "base_command", + "code", + "code_interception", + "code_channel", + "code_parameter", + "files", + "generic", + "http_endpoints", + "model_subscription", + "object_model", + "packages", + "plugins", + "responses", + "user_sessions", +] diff --git a/src/dsf/commands/base_command.py b/src/dsf/commands/base_command.py index b6dd115..18e5b49 100644 --- a/src/dsf/commands/base_command.py +++ b/src/dsf/commands/base_command.py @@ -1,5 +1,6 @@ from typing import Any + class BaseCommand: """Base class of a command.""" diff --git a/src/dsf/commands/code.py b/src/dsf/commands/code.py index d2b29d2..bd4a87e 100644 --- a/src/dsf/commands/code.py +++ b/src/dsf/commands/code.py @@ -177,4 +177,4 @@ def keyword_to_str(self) -> Optional[str]: }.get(self.keyword) def is_flag_set(self, flag: CodeFlags) -> bool: - return self.flags & flag != 0 \ No newline at end of file + return self.flags & flag != 0 diff --git a/src/dsf/commands/code_channel.py b/src/dsf/commands/code_channel.py index 83daef4..ddf2057 100644 --- a/src/dsf/commands/code_channel.py +++ b/src/dsf/commands/code_channel.py @@ -56,7 +56,7 @@ class CodeChannel(str, Enum): @staticmethod def list(): return list(map(lambda cc: cc, CodeChannel)) - + def get_input_index(self) -> int: """Get the index of this code channel for use in client init messages""" return self.list().index(self) diff --git a/src/dsf/commands/code_parameter.py b/src/dsf/commands/code_parameter.py index 4f781ee..bad72ce 100644 --- a/src/dsf/commands/code_parameter.py +++ b/src/dsf/commands/code_parameter.py @@ -1,13 +1,13 @@ """ codeparameter contains all classes and methods dealing with deserialized code parameters. """ + import json from typing import Self, TypeAlias, TypedDict, cast, Optional from ..exceptions import CodeParserException from ..object_model.move.driver_id import DriverId - CodeParameterScalar: TypeAlias = str | int | float | DriverId CodeParameterArray: TypeAlias = list[int] | list[float] | list[DriverId] CodeParameterValue: TypeAlias = CodeParameterScalar | CodeParameterArray @@ -170,7 +170,8 @@ def _parse_expression_array(self) -> list[float]: return [float(elem) for elem in elements if elem] except ValueError as e: raise Exception( - f"Cannot parse expression array: failed to convert elements to numbers in {self.letter} (value {self.string_value})" + f"Cannot parse expression array: failed to convert elements to numbers in {self.letter}" + f" (value {self.string_value})" ) from e def as_float_array(self) -> list[float]: @@ -196,10 +197,10 @@ def as_int_array(self) -> list[int]: parsed_value: object = self.__parsed_value if isinstance(parsed_value, list): if isinstance(parsed_value[0], DriverId): - values = cast(list[DriverId], parsed_value) - return [int(value.as_int()) for value in values] - values = cast(list[int] | list[float], parsed_value) - return [int(value) for value in values] + driver_ids = cast(list[DriverId], parsed_value) + return [int(driver_id.as_int()) for driver_id in driver_ids] + numbers = cast(list[int] | list[float], parsed_value) + return [int(value) for value in numbers] if isinstance(parsed_value, int): return [parsed_value] if isinstance(parsed_value, DriverId): diff --git a/src/dsf/commands/generic.py b/src/dsf/commands/generic.py index 7fda155..b7cd7ee 100644 --- a/src/dsf/commands/generic.py +++ b/src/dsf/commands/generic.py @@ -48,8 +48,9 @@ def flush(channel: CodeChannel, sync_file_streams: bool = False, if_executing: b :returns: true if the flush request is successful """ - return BaseCommand("Flush", - **{"channel": channel, "syncFileStreams": sync_file_streams, "ifExecuting": if_executing}) + return BaseCommand( + "Flush", **{"channel": channel, "syncFileStreams": sync_file_streams, "ifExecuting": if_executing} + ) def invalidate_channel(channel: CodeChannel): diff --git a/src/dsf/commands/object_model.py b/src/dsf/commands/object_model.py index 0d5cfcd..9ff350d 100644 --- a/src/dsf/commands/object_model.py +++ b/src/dsf/commands/object_model.py @@ -55,7 +55,8 @@ def sync_object_model(): def set_wifi_country(country_code: Optional[str] = None): """ - Set the WiFi country code. This is a global setting on Linux, so it is applied to every WiFi interface in the object model + Set the WiFi country code. This is a global setting on Linux, so it is applied to every WiFi interface + in the object model :param country_code: New WiFi country code, or null to clear it """ return BaseCommand("SetWifiCountry", **{"countryCode": country_code}) diff --git a/src/dsf/commands/plugins.py b/src/dsf/commands/plugins.py index eaeb835..8a3b166 100644 --- a/src/dsf/commands/plugins.py +++ b/src/dsf/commands/plugins.py @@ -34,9 +34,7 @@ def set_plugin_data(plugin: str, key: str, value: object): raise ValueError("plugin must not be empty") if not key: raise ValueError("key must not be empty") - return BaseCommand( - "SetPluginData", **{"plugin": plugin, "key": key, "value": value} - ) + return BaseCommand("SetPluginData", **{"plugin": plugin, "key": key, "value": value}) def start_plugin(plugin: str, save_state: bool = True): diff --git a/src/dsf/commands/responses.py b/src/dsf/commands/responses.py index fd59cb8..c4240cc 100644 --- a/src/dsf/commands/responses.py +++ b/src/dsf/commands/responses.py @@ -18,6 +18,7 @@ You should have received a copy of the GNU Lesser General Public License along with this program. If not, see . """ + from typing import Generic, TypeVar from ..utils import JSONObj, JSONElement @@ -27,13 +28,16 @@ class BaseResponse: """Base class for every response to a command request.""" + success: bool def __init__(self, success: bool): self.success = success + class Response(BaseResponse, Generic[TResult]): """Response of a Command""" + result: TResult def __init__(self, result: TResult): @@ -43,6 +47,7 @@ def __init__(self, result: TResult): class ErrorResponse(BaseResponse): """Response indicating a runtime exception during the internal processing of a command""" + error_type: str error_message: str diff --git a/src/dsf/connections/__init__.py b/src/dsf/connections/__init__.py index ac5ece3..3db0b57 100644 --- a/src/dsf/connections/__init__.py +++ b/src/dsf/connections/__init__.py @@ -50,3 +50,16 @@ class SubscriptionMode(str, Enum): from .exceptions import InternalServerException, TaskCanceledException from .intercept_connection import InterceptConnection from .subscribe_connection import SubscribeConnection + +__all__ = [ + "ConnectionMode", + "InterceptionMode", + "SubscriptionMode", + "BaseCommandConnection", + "BaseConnection", + "CommandConnection", + "InternalServerException", + "TaskCanceledException", + "InterceptConnection", + "SubscribeConnection", +] diff --git a/src/dsf/connections/base_command_connection.py b/src/dsf/connections/base_command_connection.py index b3b1179..3947413 100644 --- a/src/dsf/connections/base_command_connection.py +++ b/src/dsf/connections/base_command_connection.py @@ -144,10 +144,7 @@ def perform_code(self, cde: code.Code) -> Optional[Message]: return res.result def perform_simple_code( - self, - cde: str, - channel: CodeChannel = CodeChannel.DEFAULT_CHANNEL, - async_exec: bool = False + self, cde: str, channel: CodeChannel = CodeChannel.DEFAULT_CHANNEL, async_exec: bool = False ) -> str: """Execute an arbitrary G/M/T-code in text form @@ -171,9 +168,7 @@ def reload_plugin(self, plugin: str): def remove_http_endpoint(self, endpoint_type: HttpEndpointType, namespace: str, path: str): """Remove an existing HTTP endpoint""" - res = self.perform_command( - commands.http_endpoints.remove_http_endpoint(endpoint_type, namespace, path) - ) + res = self.perform_command(commands.http_endpoints.remove_http_endpoint(endpoint_type, namespace, path)) return res.result def remove_user_session(self, session_id: int): @@ -196,7 +191,8 @@ def set_network_protocol(self, protocol: str, enabled: bool): def set_wifi_country(self, country_code: Optional[str] = None): """ - Set the WiFi country code. This is a global setting on Linux, so it is applied to every WiFi interface in the object model + Set the WiFi country code. This is a global setting on Linux, so it is applied to every WiFi interface + in the object model :param country_code: New WiFi country code, or null to clear it """ return self.perform_command(commands.object_model.set_wifi_country(country_code)) @@ -211,7 +207,8 @@ def set_update_status(self, is_updating: bool, message: str = "", progress: Opti Override the current machine status if a software update is in progress :param is_updating: Whether an update is now in progress :param message: Description of the current update step, only used if is_updating is true - :param progress: Progress of the current update step (0..1) or None if indeterminate, only used if is_updating is true + :param progress: Progress of the current update step (0..1) or None if indeterminate, + only used if is_updating is true """ res = self.perform_command(commands.generic.set_update_status(is_updating, message, progress)) return res.result @@ -267,7 +264,5 @@ def write_message( log_level: LogLevel, ): """Write an arbitrary message""" - res = self.perform_command( - commands.generic.write_message(message_type, message, output_message, log_level) - ) + res = self.perform_command(commands.generic.write_message(message_type, message, output_message, log_level)) return res.result diff --git a/src/dsf/connections/base_connection.py b/src/dsf/connections/base_connection.py index 8f496f1..1b210c5 100644 --- a/src/dsf/connections/base_connection.py +++ b/src/dsf/connections/base_connection.py @@ -2,7 +2,7 @@ import select import socket import time -from typing import Optional, overload +from typing import Any, Optional, Protocol, Self, TypeVar, overload from .exceptions import IncompatibleVersionException, InternalServerException, TaskCanceledException from .init_messages import client_init_messages, server_init_message @@ -12,6 +12,15 @@ from ..object_model.model_object import TModelObject from ..utils import JSONElement + +class _JSONDeserializable(Protocol): + @classmethod + def from_json(cls, data: Any) -> Self: ... + + +TDeserializable = TypeVar("TDeserializable", bound=_JSONDeserializable) + + class BaseConnection: """ Base class for connections that access the control server via the Duet API @@ -22,7 +31,7 @@ def __init__(self, debug: bool = False, timeout: int = 3): self.debug = debug self.timeout = timeout self.socket: Optional[socket.socket] = None - self.id = None + self.id: Optional[int] = None self.input = "" def _connect(self, init_message: client_init_messages.ClientInitMessage, socket_file: str): @@ -88,9 +97,7 @@ def perform_command( if response.error_type == "TaskCanceledException": raise TaskCanceledException(response.error_message) - raise InternalServerException( - command, response.error_type, response.error_message - ) + raise InternalServerException(command, response.error_type, response.error_message) def send(self, msg: object): """Serialize an arbitrary object into JSON and send it to the server plus NL""" @@ -100,7 +107,7 @@ def send(self, msg: object): if self.socket: self.socket.sendall(json_string.encode("utf8")) - def receive(self, cls: type[TModelObject]) -> TModelObject: + def receive(self, cls: type[TDeserializable]) -> TDeserializable: """Receive a deserialized object from the server""" json_string = self.receive_json() return cls.from_json(json.loads(json_string)) @@ -145,16 +152,18 @@ def receive_json(self) -> str: # Refill the buffer and check again BUFF_SIZE = 4096 # 4 KiB data = b"" - part = b"" + closed = False while True: try: part = self.socket.recv(BUFF_SIZE) - data += part except socket.timeout: - pass - except Exception as e: - raise e - # either 0 or end of data + break + if not part: + # Parse what was already received before reporting the closed connection + closed = True + break + data += part + # end of the currently available data if len(part) < BUFF_SIZE: break @@ -167,6 +176,8 @@ def receive_json(self) -> str: # Limit to the first full JSON object json_string = json_string[:end_index] found = True + elif closed: + raise ConnectionError("Connection closed by the server") if self.debug: print("recv:", json_string) diff --git a/src/dsf/connections/exceptions.py b/src/dsf/connections/exceptions.py index 7bac18a..0c68d5f 100644 --- a/src/dsf/connections/exceptions.py +++ b/src/dsf/connections/exceptions.py @@ -1,5 +1,6 @@ from ..commands.base_command import BaseCommand + class IncompatibleVersionException(Exception): """Exception raised if the API version of the client is incompatible to the server""" diff --git a/src/dsf/connections/init_messages/__init__.py b/src/dsf/connections/init_messages/__init__.py index 4c35560..1a85361 100644 --- a/src/dsf/connections/init_messages/__init__.py +++ b/src/dsf/connections/init_messages/__init__.py @@ -1 +1,3 @@ from . import client_init_messages, server_init_message + +__all__ = ["client_init_messages", "server_init_message"] diff --git a/src/dsf/connections/init_messages/client_init_messages.py b/src/dsf/connections/init_messages/client_init_messages.py index dfba6f8..85dadc3 100644 --- a/src/dsf/connections/init_messages/client_init_messages.py +++ b/src/dsf/connections/init_messages/client_init_messages.py @@ -17,6 +17,7 @@ You should have received a copy of the GNU Lesser General Public License along with this program. If not, see . """ + from typing import List, Any from .server_init_message import ServerInitMessage from .. import ConnectionMode, InterceptionMode, SubscriptionMode @@ -37,12 +38,13 @@ def __init__(self, mode: ConnectionMode = ConnectionMode.UNKNOWN, **kwargs: Any) def intercept_init_message( - intercept_mode: InterceptionMode, - channels: List[CodeChannel], - filters: List[str], - priority_codes: bool, - auto_flush: bool = True, - auto_evaluate_expression: bool = True) -> ClientInitMessage: + intercept_mode: InterceptionMode, + channels: List[CodeChannel], + filters: List[str], + priority_codes: bool, + auto_flush: bool = True, + auto_evaluate_expression: bool = True, +) -> ClientInitMessage: """ Enter interception mode Whenever a code is received, the connection must respond with one of @@ -85,16 +87,19 @@ def command_init_message() -> ClientInitMessage: return ClientInitMessage(ConnectionMode.COMMAND) -def subscribe_init_message(subscription_mode: SubscriptionMode, filter_list: List[str] = [], verbose: bool = False, obsolete: bool = False) -> ClientInitMessage: +def subscribe_init_message( + subscription_mode: SubscriptionMode, filter_list: List[str] = [], verbose: bool = False, obsolete: bool = False +) -> ClientInitMessage: """_summary_ Args: subscription_mode (SubscriptionMode): FULL to receive the entire OM every request, PATCH to only get the changes - filter_list (List[str], optional): + filter_list (List[str], optional): The style of a filter is similar to XPath. For example, if you want to monitor only the current heater temperatures, you can use the filter expression "heat/heaters[*]/current". Wildcards are supported either - for full names or indices. To get updates for an entire namespace, the ** wildcard can be used - for example heat/** for everything heat-related), however it can be only used at the end of a filter expression. + for full names or indices. To get updates for an entire namespace, the ** wildcard can be used + for example heat/** for everything heat-related), however it can be only used at the end of a filter + expression. Defaults to []. verbose (bool): whether object model fields flagged as verbose are required. obsolete (bool): whether object model fields flagged as obsolete are required. diff --git a/src/dsf/connections/init_messages/server_init_message.py b/src/dsf/connections/init_messages/server_init_message.py index 49fae60..70806ca 100644 --- a/src/dsf/connections/init_messages/server_init_message.py +++ b/src/dsf/connections/init_messages/server_init_message.py @@ -19,7 +19,7 @@ """ from ... import PROTOCOL_VERSION -from ...utils import preserve_builtin, JSONObj +from ...utils import JSONObj class ServerInitMessage: @@ -29,7 +29,7 @@ class ServerInitMessage: """ @classmethod - def from_json(cls, data: JSONObj) -> 'ServerInitMessage': + def from_json(cls, data: JSONObj) -> "ServerInitMessage": """Deserialize a dictionary coming from JSON into an instance of this class""" version = data.get("version") id = data.get("id") diff --git a/src/dsf/connections/intercept_connection.py b/src/dsf/connections/intercept_connection.py index 94e55e5..c0126c8 100644 --- a/src/dsf/connections/intercept_connection.py +++ b/src/dsf/connections/intercept_connection.py @@ -37,7 +37,7 @@ def __init__( auto_evaluate_expression: bool = True, priority_codes: bool = False, debug: bool = False, - timeout: int = 0 + timeout: int = 0, ): super().__init__(debug, timeout) self.interception_mode = interception_mode @@ -55,7 +55,7 @@ def connect(self, socket_file: str = SOCKET_FILE): # noqa self.filters, self.priority_codes, self.auto_flush, - self.auto_evaluate_expression + self.auto_evaluate_expression, ) return super()._connect(iim, socket_file) diff --git a/src/dsf/connections/subscribe_connection.py b/src/dsf/connections/subscribe_connection.py index 6d80d9a..32f37e7 100644 --- a/src/dsf/connections/subscribe_connection.py +++ b/src/dsf/connections/subscribe_connection.py @@ -1,7 +1,7 @@ import json from dataclasses import dataclass from threading import Lock -from typing import Any, Callable, Sequence, List +from typing import Any, Callable, Protocol, Sequence, List, cast from .base_connection import BaseConnection from .init_messages import client_init_messages @@ -9,14 +9,19 @@ from ..object_model import ObjectModel from ..utils import JSONObj - _MISSING = object() +class KeySubscriptionCallback(Protocol): + """Callback invoked for a subscribed object model key""" + + def __call__(self, *, key: str, data: Any, indices: tuple[int, ...] | None) -> None: ... + + @dataclass(frozen=True) class _ObjectModelCallbackSubscription: keys: tuple[str, ...] - callback: Callable[..., None] + callback: KeySubscriptionCallback class SubscribeConnection(BaseConnection): @@ -67,13 +72,16 @@ def get_object_model(self) -> ObjectModel: Later calls apply every queued patch without blocking, update the cached object model, and run any registered key callbacks synchronously. """ - if (self.subscription_mode == client_init_messages.SubscriptionMode.FULL or not self._initial_object_model_received): + if ( + self.subscription_mode == client_init_messages.SubscriptionMode.FULL + or not self._initial_object_model_received + ): self._object_model = self.receive(ObjectModel) self._initial_object_model_received = True self.send(commands.model_subscription.acknowledge()) return self._object_model else: - while (self.has_data_available()): + while self.has_data_available(): patch_json = self.get_object_model_patch() patch_data = json.loads(patch_json) self._object_model.update_from_json(patch_data) @@ -104,7 +112,7 @@ def get_object_model_patch(self) -> str: def subscribe_to_keys( self, keys: Sequence[str], - callback: Callable[..., None], + callback: KeySubscriptionCallback, ) -> Callable[[], None]: """ Register a callback for one or more dot-delimited object model key paths. @@ -171,10 +179,7 @@ def _extract_key_path_values( key: str, ) -> list[tuple[tuple[int, ...] | None, Any]]: matches = cls._walk_key_path(patch_data, key.split("."), ()) - return [ - (indexes if indexes else None, value) - for indexes, value in matches - ] + return [(indexes if indexes else None, value) for indexes, value in matches] @classmethod def _walk_key_path( @@ -195,9 +200,10 @@ def _walk_key_path( return cls._walk_key_path(current_value[part], next_parts, indexes) if isinstance(current_value, list): + items = cast(list[Any], current_value) if part == "^": matches: list[tuple[tuple[int, ...], Any]] = [] - for index, item in enumerate(current_value): + for index, item in enumerate(items): if item is None: continue matches.extend(cls._walk_key_path(item, next_parts, indexes + (index,))) @@ -207,9 +213,9 @@ def _walk_key_path( index = int(part) except ValueError: return [] - if index < 0 or index >= len(current_value): + if index < 0 or index >= len(items): return [] - item = current_value[index] + item = items[index] if item is None: return [] return cls._walk_key_path(item, next_parts, indexes) @@ -218,7 +224,7 @@ def _walk_key_path( @staticmethod def _invoke_callback( - callback: Callable[..., None], + callback: KeySubscriptionCallback, *, key: str, data: Any, diff --git a/src/dsf/http.py b/src/dsf/http.py index 1d01271..1d6b92d 100644 --- a/src/dsf/http.py +++ b/src/dsf/http.py @@ -14,6 +14,7 @@ HttpCallback: TypeAlias = Callable[["HttpEndpointConnection"], Coroutine[Any, Any, None]] + class HttpResponseType(str, Enum): """Enumeration of supported HTTP responses""" @@ -43,7 +44,9 @@ def from_json(cls, data: JSONObj) -> "ReceivedHttpRequest": body=body, ) - def __init__(self, sessionId: int, queries: dict[str, str], headers: dict[str, str], contentType: Optional[str], body: str): + def __init__( + self, sessionId: int, queries: dict[str, str], headers: dict[str, str], contentType: Optional[str], body: str + ): self.session_id = sessionId self.queries = queries self.headers = headers @@ -54,7 +57,9 @@ def __init__(self, sessionId: int, queries: dict[str, str], headers: dict[str, s class HttpEndpointConnection: """Connection class for dealing with requests received from a custom HTTP endpoint""" - def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, is_websocket: bool, debug: bool = False): + def __init__( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, is_websocket: bool, debug: bool = False + ): """Constructor for a new connection dealing with a single HTTP endpoint request""" self.reader = reader self.writer = writer @@ -172,7 +177,7 @@ def _create_socket(self, path: str): sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) # Check for abstract socket. `str` and `bytes` paths are supported. - if path[0] not in (0, '\x00'): + if path[0] not in (0, "\x00"): try: if stat.S_ISSOCK(os.stat(path).st_mode): os.remove(path) @@ -192,11 +197,11 @@ def _create_socket(self, path: str): if exc.errno == errno.EADDRINUSE: # Let's improve the error message by adding # with what exact address it occurs. - msg = f'Address {path!r} is already in use' + msg = f"Address {path!r} is already in use" raise OSError(errno.EADDRINUSE, msg) from None else: raise - except: + except BaseException: sock.close() raise @@ -208,9 +213,7 @@ def start_connection_listener(self): asyncio.set_event_loop(self._loop) sock = self._create_socket(self.socket_file) self._server = self._loop.run_until_complete( - asyncio.start_unix_server( - self.handle_connection, sock=sock, backlog=self.backlog - ) + asyncio.start_unix_server(self.handle_connection, sock=sock, backlog=self.backlog) ) self._loop.run_forever() finally: diff --git a/src/dsf/object_model/__init__.py b/src/dsf/object_model/__init__.py index 8540a77..9f98aa3 100644 --- a/src/dsf/object_model/__init__.py +++ b/src/dsf/object_model/__init__.py @@ -13,8 +13,79 @@ from .plugins import Plugin, PluginManifest, SbcPermissions from .sbc import CPU, Memory, SBC, Upgrade from .sbc.dsf import AccessLevel, HttpEndpoint, HttpEndpointType, SessionType, UserSession -from .sensors import Accelerometer, AnalogSensor, AnalogSensorType, Endstop, EndstopType, GpInputPort, Probe, ProbeLoadCell, ProbeType, Sensors +from .sensors import ( + Accelerometer, + AnalogSensor, + AnalogSensorType, + Endstop, + EndstopType, + GpInputPort, + Probe, + ProbeLoadCell, + ProbeType, + Sensors, +) from .spindles import Spindle, SpindleState from .state import LogLevel, MachineStatus, MessageBox, MessageBoxMode, State from .tools import Tool, ToolState from .volumes import Volume + +__all__ = [ + "Board", + "Boards", + "BoardState", + "ExpansionBoard", + "MainBoard", + "Directories", + "Fan", + "Heat", + "Heater", + "HeaterState", + "InputChannel", + "Job", + "LedStrip", + "LedStripType", + "Limits", + "Message", + "MessageType", + "DriverId", + "Move", + "Network", + "NetworkInterface", + "NetworkInterfaceType", + "NetworkProtocol", + "NetworkState", + "ObjectModel", + "Plugin", + "PluginManifest", + "SbcPermissions", + "CPU", + "Memory", + "SBC", + "Upgrade", + "AccessLevel", + "HttpEndpoint", + "HttpEndpointType", + "SessionType", + "UserSession", + "Accelerometer", + "AnalogSensor", + "AnalogSensorType", + "Endstop", + "EndstopType", + "GpInputPort", + "Probe", + "ProbeLoadCell", + "ProbeType", + "Sensors", + "Spindle", + "SpindleState", + "LogLevel", + "MachineStatus", + "MessageBox", + "MessageBoxMode", + "State", + "Tool", + "ToolState", + "Volume", +] diff --git a/src/dsf/object_model/boards/__init__.py b/src/dsf/object_model/boards/__init__.py index 051980f..ae95b7c 100644 --- a/src/dsf/object_model/boards/__init__.py +++ b/src/dsf/object_model/boards/__init__.py @@ -5,5 +5,17 @@ from .driver_closed_loop import DriverClosedLoop, ClosedLoopCurrentFraction, ClosedLoopPositionError from .min_max_current import MinMaxCurrent -__all__ = ['Board', 'Boards', 'BoardState', 'ExpansionBoard', 'MainBoard', 'BoardClosedLoop', 'DirectDisplay', 'Driver', - 'DriverClosedLoop', 'ClosedLoopCurrentFraction', 'ClosedLoopPositionError', 'MinMaxCurrent'] +__all__ = [ + "Board", + "Boards", + "BoardState", + "ExpansionBoard", + "MainBoard", + "BoardClosedLoop", + "DirectDisplay", + "Driver", + "DriverClosedLoop", + "ClosedLoopCurrentFraction", + "ClosedLoopPositionError", + "MinMaxCurrent", +] diff --git a/src/dsf/object_model/boards/boards.py b/src/dsf/object_model/boards/boards.py index 8879d76..e1437a1 100644 --- a/src/dsf/object_model/boards/boards.py +++ b/src/dsf/object_model/boards/boards.py @@ -42,43 +42,43 @@ class Board(ModelObject): """ # CAN address of this board or None if not applicable - can_address = nullable_model_prop('can_address', int) + can_address = nullable_model_prop("can_address", int) # Drivers of this board - drivers = nullable_model_prop('drivers', ModelCollection[Driver], lambda: ModelCollection(Driver)) + drivers = nullable_model_prop("drivers", ModelCollection[Driver], lambda: ModelCollection(Driver)) # Date of the firmware build - firmware_date = model_prop('firmware_date', str, "") + firmware_date = model_prop("firmware_date", str, "") # Filename of the firmware binary - firmware_file_name = model_prop('firmware_file_name', str, "") + firmware_file_name = model_prop("firmware_file_name", str, "") # Version of the firmware build - firmware_version = model_prop('firmware_version', str, "") + firmware_version = model_prop("firmware_version", str, "") # Amount of free RAM on this board (in bytes or null if unknown) - free_ram = nullable_model_prop('free_ram', int) + free_ram = nullable_model_prop("free_ram", int) # Maximum number of motors this board can drive - max_motors = model_prop('max_motors', int) + max_motors = model_prop("max_motors", int) # Minimum, maximum, and current temperatures of the MCU or None if unknown - mcu_temp = nullable_model_prop('mcu_temp', MinMaxCurrent) + mcu_temp = nullable_model_prop("mcu_temp", MinMaxCurrent) # Full name of the board - name = model_prop('name', str, "") + name = model_prop("name", str, "") # Short name of the board - short_name = model_prop('short_name', str, "") + short_name = model_prop("short_name", str, "") # Unique identifier of the board or None if unknown - unique_id = nullable_model_prop('unique_id', str) + unique_id = nullable_model_prop("unique_id", str) # Minimum, maximum, and current voltages on the 12V rail or None if unknown - v_12 = nullable_model_prop('v_12', MinMaxCurrent) + v_12 = nullable_model_prop("v_12", MinMaxCurrent) # Minimum, maximum, and current voltages on the input rail or None if unknown - v_in = nullable_model_prop('v_in', MinMaxCurrent) + v_in = nullable_model_prop("v_in", MinMaxCurrent) def __init__(self): super(Board, self).__init__() @@ -88,25 +88,25 @@ class MainBoard(Board): """Information about the mainboard, which is always the first item of the boards list""" # Details about a connected display or None if none is connected - direct_display = nullable_model_prop('direct_display', DirectDisplay) + direct_display = nullable_model_prop("direct_display", DirectDisplay) # Name of the firmware build - firmware_name = model_prop('firmware_name', str, "") + firmware_name = model_prop("firmware_name", str, "") # Filename of the IAP binary that is used for updates from the SBC or None if unsupported - iap_file_name_SBC = nullable_model_prop('iap_file_name_SBC', str) + iap_file_name_SBC = nullable_model_prop("iap_file_name_SBC", str) # Filename of the IAP binary that is used for updates from the SD card or None if unsupported - iap_file_name_SD = nullable_model_prop('iap_file_name_SD', str) + iap_file_name_SD = nullable_model_prop("iap_file_name_SD", str) # Maximum number of heaters this board can control - max_heaters = model_prop('max_heaters', int) + max_heaters = model_prop("max_heaters", int) # Indicates if this board supports external displays - supports_direct_display = model_prop('supports_direct_display', bool, False) + supports_direct_display = model_prop("supports_direct_display", bool, False) # Filename of the on-board WiFi chip or None if not present - wifi_firmware_file_name = nullable_model_prop('wifi_firmware_file_name', str) + wifi_firmware_file_name = nullable_model_prop("wifi_firmware_file_name", str) def __init__(self): super(MainBoard, self).__init__() @@ -116,23 +116,26 @@ class ExpansionBoard(Board): """Information about an expansion board connected over CAN""" # Closed loop data of this board or None if unknown - closed_loop = nullable_model_prop('closed_loop', BoardClosedLoop) + closed_loop = nullable_model_prop("closed_loop", BoardClosedLoop) # Information about an inductive sensor or None if not present - inductive_sensor = nullable_model_prop('inductive_sensor', InductiveSensor) + inductive_sensor = nullable_model_prop("inductive_sensor", InductiveSensor) # State of this board - state = model_prop('state', BoardState, BoardState.unknown) + state = model_prop("state", BoardState, BoardState.unknown) # Connection timeout of this board (in s) - timeout = model_prop('timeout', int, 10) + timeout = model_prop("timeout", int, 10) def __init__(self): super(ExpansionBoard, self).__init__() class Boards(ModelCollection[Board]): - """List of connected boards. The first board is the mainboard, every other one is an expansion board connected over CAN""" + """ + List of connected boards. + The first board is the mainboard, every other one is an expansion board connected over CAN + """ def __init__(self, value: Optional[List[Board]] = None): super(Boards, self).__init__(Board, value) diff --git a/src/dsf/object_model/boards/direct_display/__init__.py b/src/dsf/object_model/boards/direct_display/__init__.py index b1f904d..da56744 100644 --- a/src/dsf/object_model/boards/direct_display/__init__.py +++ b/src/dsf/object_model/boards/direct_display/__init__.py @@ -4,5 +4,10 @@ from .direct_display_screen import DirectDisplayScreen from .direct_display_screen_st7567 import DirectDisplayScreenST7567 -__all__ = ['DirectDisplay', 'DirectDisplayController', 'DirectDisplayEncoder', - 'DirectDisplayScreen', 'DirectDisplayScreenST7567'] +__all__ = [ + "DirectDisplay", + "DirectDisplayController", + "DirectDisplayEncoder", + "DirectDisplayScreen", + "DirectDisplayScreenST7567", +] diff --git a/src/dsf/object_model/boards/direct_display/direct_display.py b/src/dsf/object_model/boards/direct_display/direct_display.py index f520557..19fd2e2 100644 --- a/src/dsf/object_model/boards/direct_display/direct_display.py +++ b/src/dsf/object_model/boards/direct_display/direct_display.py @@ -9,10 +9,10 @@ class DirectDisplay(ModelObject): """Class providing information about a connected direct-connect display""" # Encoder of this screen or null if none - encoder = nullable_model_prop('encoder', DirectDisplayEncoder) - + encoder = nullable_model_prop("encoder", DirectDisplayEncoder) + # Screen information - screen = model_prop('screen', DirectDisplayScreen, DirectDisplayScreen()) + screen = model_prop("screen", DirectDisplayScreen, DirectDisplayScreen()) def __init__(self): super(DirectDisplay, self).__init__() diff --git a/src/dsf/object_model/boards/direct_display/direct_display_screen.py b/src/dsf/object_model/boards/direct_display/direct_display_screen.py index 933ab7b..772e0cb 100644 --- a/src/dsf/object_model/boards/direct_display/direct_display_screen.py +++ b/src/dsf/object_model/boards/direct_display/direct_display_screen.py @@ -8,20 +8,20 @@ class DirectDisplayScreen(ModelObject): """Class providing information about a connected display screen""" # Number of colour bits - colour_bits = model_prop("colour_bits",int, 1) - + colour_bits = model_prop("colour_bits", int, 1) + # Display type controller = model_prop("controller", DirectDisplayController, DirectDisplayController.ST7920) - + # Height of the display screen in pixels - height = model_prop("height",int, 64) - + height = model_prop("height", int, 64) + # SPI frequency of the display (in Hz) - spi_freq = model_prop("spi_freq",int, 0) - + spi_freq = model_prop("spi_freq", int, 0) + # Width of the display screen in pixels - width = model_prop("width",int, 128) - + width = model_prop("width", int, 128) + def __init__(self, controller: DirectDisplayController = DirectDisplayController.ST7920): super().__init__() @@ -43,8 +43,8 @@ def get_direct_display_screen_type(type_: DirectDisplayController): def _update_from_json(self, **kwargs: JSONElement): """Override ObjectModel._update_from_json to return the DirectDisplayScreen type matching the given controller""" - if 'controller' in kwargs: - controller = DirectDisplayController(kwargs.get('controller')) + if "controller" in kwargs: + controller = DirectDisplayController(kwargs.get("controller")) if controller != self.controller: required_type = self.get_direct_display_screen_type(controller) new_direct_display_screen = required_type.update_from_json(kwargs) diff --git a/src/dsf/object_model/boards/driver.py b/src/dsf/object_model/boards/driver.py index 236fe96..848e09e 100644 --- a/src/dsf/object_model/boards/driver.py +++ b/src/dsf/object_model/boards/driver.py @@ -8,10 +8,10 @@ class Driver(ModelObject): """Information about a driver""" # Closed-loop settings (if applicable) - closed_loop = nullable_model_prop('closed_loop', DriverClosedLoop) + closed_loop = nullable_model_prop("closed_loop", DriverClosedLoop) # Configured (M569) settings of this driver - config = model_prop('config', DriverConfig) + config = model_prop("config", DriverConfig) # Driver status register value status = model_prop("status", int) diff --git a/src/dsf/object_model/boards/driver_closed_loop.py b/src/dsf/object_model/boards/driver_closed_loop.py index a7fcaf8..b16d307 100644 --- a/src/dsf/object_model/boards/driver_closed_loop.py +++ b/src/dsf/object_model/boards/driver_closed_loop.py @@ -30,7 +30,7 @@ def __init__(self): class DriverClosedLoop(ModelObject): """This represents information about closed-loop tuning""" - + # Current fraction of the configured motor current used current_fraction = model_prop("current_fraction", ClosedLoopCurrentFraction) diff --git a/src/dsf/object_model/boards/driver_config.py b/src/dsf/object_model/boards/driver_config.py index a28abdc..e57895b 100644 --- a/src/dsf/object_model/boards/driver_config.py +++ b/src/dsf/object_model/boards/driver_config.py @@ -7,10 +7,10 @@ class DriverConfig(ModelObject): """Configured (M569) settings of a driver""" # Configured direction of the driver (false = reverse, true = forward) - direction = model_prop('direction', bool, True) + direction = model_prop("direction", bool, True) # Configured driver mode (only available for smart drivers) - mode = nullable_model_prop('mode', DriverMode, lambda: None) + mode = nullable_model_prop("mode", DriverMode, lambda: None) def __init__(self): super(DriverConfig, self).__init__() diff --git a/src/dsf/object_model/boards/driver_mode.py b/src/dsf/object_model/boards/driver_mode.py index 2176f66..f102715 100644 --- a/src/dsf/object_model/boards/driver_mode.py +++ b/src/dsf/object_model/boards/driver_mode.py @@ -5,22 +5,22 @@ class DriverMode(int, Enum): """State of a channel""" # Constant off-time chopper - constantOffTime = 0, + constantOffTime = (0,) # Random off-time chopper - randomOffTime = 1, + randomOffTime = (1,) # SpreadCycle - spreadCycle = 2, + spreadCycle = (2,) # StealthChop (includes stealthChop2) - stealthChop = 3, + stealthChop = (3,) # Field-oriented control (direct) - direct = 4, + direct = (4,) # Assisted open loop - assistedOpen = 5, + assistedOpen = (5,) # Driver mode is unknown unknown = 6 diff --git a/src/dsf/object_model/boards/min_max_current.py b/src/dsf/object_model/boards/min_max_current.py index 288ea4d..c489005 100644 --- a/src/dsf/object_model/boards/min_max_current.py +++ b/src/dsf/object_model/boards/min_max_current.py @@ -1,12 +1,13 @@ from ..model_object import ModelObject from ..utils import model_prop + class MinMaxCurrent(ModelObject): """Provides minimum, maximum and current values""" # Current value current = model_prop("current", float) - + # Minimum value min = model_prop("min", float) @@ -15,4 +16,3 @@ class MinMaxCurrent(ModelObject): def __init__(self): super(MinMaxCurrent, self).__init__() - \ No newline at end of file diff --git a/src/dsf/object_model/directories/__init__.py b/src/dsf/object_model/directories/__init__.py index e3bdbb7..ca5e9db 100644 --- a/src/dsf/object_model/directories/__init__.py +++ b/src/dsf/object_model/directories/__init__.py @@ -1,3 +1,3 @@ -from .directories import * +from .directories import Directories -__all__ = ['Directories'] +__all__ = ["Directories"] diff --git a/src/dsf/object_model/fans/__init__.py b/src/dsf/object_model/fans/__init__.py index 437207f..af0a0d1 100644 --- a/src/dsf/object_model/fans/__init__.py +++ b/src/dsf/object_model/fans/__init__.py @@ -1,4 +1,4 @@ from .fans import Fan from .fan_thermostatic_control import FanThermostaticControl -__all__ = ['Fan', 'FanThermostaticControl'] +__all__ = ["Fan", "FanThermostaticControl"] diff --git a/src/dsf/object_model/heat/__init__.py b/src/dsf/object_model/heat/__init__.py index aa666a7..d70a360 100644 --- a/src/dsf/object_model/heat/__init__.py +++ b/src/dsf/object_model/heat/__init__.py @@ -4,5 +4,13 @@ from .heater_model_pid import HeaterModelPID from .heater_monitor import HeaterMonitor, HeaterMonitorAction, HeaterMonitorCondition -__all__ = ['Heat', 'Heater', 'HeaterState', 'HeaterModel', 'HeaterModelPID', 'HeaterMonitor', 'HeaterMonitorAction', - 'HeaterMonitorCondition'] +__all__ = [ + "Heat", + "Heater", + "HeaterState", + "HeaterModel", + "HeaterModelPID", + "HeaterMonitor", + "HeaterMonitorAction", + "HeaterMonitorCondition", +] diff --git a/src/dsf/object_model/heat/heat.py b/src/dsf/object_model/heat/heat.py index 6cdbcc4..c9f3726 100644 --- a/src/dsf/object_model/heat/heat.py +++ b/src/dsf/object_model/heat/heat.py @@ -21,7 +21,9 @@ class Heat(ModelObject): chamber_heaters = model_prop("chamber_heaters", ModelCollection[int], ModelCollection(int)) # List of configured chamber heaters (indices), grouped by chamber (first dimension) - chamber_heater_mapping = model_prop("chamber_heater_mapping", ModelCollection[list[int]], ModelCollection(list[int])) + chamber_heater_mapping = model_prop( + "chamber_heater_mapping", ModelCollection[list[int]], ModelCollection(list[int]) + ) # Minimum required temperature for extrusion moves (in C) cold_extrude_temperature = model_prop("cold_extrude_temperature", float, 160) diff --git a/src/dsf/object_model/inputs/__init__.py b/src/dsf/object_model/inputs/__init__.py index e7abdd5..3924085 100644 --- a/src/dsf/object_model/inputs/__init__.py +++ b/src/dsf/object_model/inputs/__init__.py @@ -4,4 +4,4 @@ from .input_channel import InputChannel from .input_channel_state import InputChannelState -__all__ = ['Compatibility', 'DistanceUnit', 'Inputs', 'InputChannel', 'InputChannelState'] +__all__ = ["Compatibility", "DistanceUnit", "Inputs", "InputChannel", "InputChannelState"] diff --git a/src/dsf/object_model/job/__init__.py b/src/dsf/object_model/job/__init__.py index fab2216..e7df336 100644 --- a/src/dsf/object_model/job/__init__.py +++ b/src/dsf/object_model/job/__init__.py @@ -6,5 +6,4 @@ from .thumbnail_info import ThumbnailInfo, ThumbnailInfoFormat from .times_left import TimesLeft - -__all__ = ['Build', 'BuildObject', 'GCodeFileInfo', 'Job', 'Layer', 'ThumbnailInfo', 'ThumbnailInfoFormat', 'TimesLeft'] +__all__ = ["Build", "BuildObject", "GCodeFileInfo", "Job", "Layer", "ThumbnailInfo", "ThumbnailInfoFormat", "TimesLeft"] diff --git a/src/dsf/object_model/job/build.py b/src/dsf/object_model/job/build.py index 784c809..f10b625 100644 --- a/src/dsf/object_model/job/build.py +++ b/src/dsf/object_model/job/build.py @@ -1,5 +1,3 @@ -from typing import List - from .build_object import BuildObject from ..model_collection import ModelCollection from ..model_object import ModelObject diff --git a/src/dsf/object_model/job/gcode_fileinfo.py b/src/dsf/object_model/job/gcode_fileinfo.py index 8cda98a..fbc5374 100644 --- a/src/dsf/object_model/job/gcode_fileinfo.py +++ b/src/dsf/object_model/job/gcode_fileinfo.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import List, Union from .thumbnail_info import ThumbnailInfo from ..model_collection import ModelCollection diff --git a/src/dsf/object_model/job/job.py b/src/dsf/object_model/job/job.py index 437de9c..5f7bc95 100644 --- a/src/dsf/object_model/job/job.py +++ b/src/dsf/object_model/job/job.py @@ -1,5 +1,3 @@ -from typing import List, Union - from .build import Build from .gcode_fileinfo import GCodeFileInfo from .layer import Layer @@ -13,39 +11,39 @@ class Job(ModelObject): """Information about the current job""" # Information about the current build or None if not available - build = nullable_model_prop('build', Build) + build = nullable_model_prop("build", Build) # Total active duration of the current job file (in s or None) - duration = nullable_model_prop('duration', int) + duration = nullable_model_prop("duration", int) # Information about the file being processed - file = model_prop('file', GCodeFileInfo) + file = model_prop("file", GCodeFileInfo) # Current position in the file being processed (in bytes or None) - file_position = nullable_model_prop('file_position', int) + file_position = nullable_model_prop("file_position", int) # Total duration of the last job (in s or None) - last_duration = nullable_model_prop('last_duration', int) + last_duration = nullable_model_prop("last_duration", int) # Name of the last file processed or None - last_file_name = nullable_model_prop('last_file_name', str) + last_file_name = nullable_model_prop("last_file_name", str) # Indicates if the last file was aborted (unexpected cancellation) - last_file_aborted = model_prop('last_file_aborted', bool, False) + last_file_aborted = model_prop("last_file_aborted", bool, False) # Indicates if the last file was cancelled (user cancelled) - last_file_cancelled = model_prop('last_file_cancelled', bool, False) + last_file_cancelled = model_prop("last_file_cancelled", bool, False) # Indicates if the last file processed was simulated - last_file_simulated = model_prop('last_file_simulated', bool, False) + last_file_simulated = model_prop("last_file_simulated", bool, False) # Warm-up duration of the last print or None if not available (in s) - last_warm_up_duration = nullable_model_prop('last_warm_up_duration', int) + last_warm_up_duration = nullable_model_prop("last_warm_up_duration", int) # Number of the current layer or None if not available - layer = nullable_model_prop('layer', int) + layer = nullable_model_prop("layer", int) # Information about the past layers - layers = model_prop('layers', ModelCollection[Layer], ModelCollection(Layer)) + layers = model_prop("layers", ModelCollection[Layer], ModelCollection(Layer)) # Time elapsed since the last layer change (in s or None) - layer_time = nullable_model_prop('layer_time', float) + layer_time = nullable_model_prop("layer_time", float) # Total pause time since the job started - pause_duration = nullable_model_prop('pause_duration', int) + pause_duration = nullable_model_prop("pause_duration", int) # Total extrusion amount without extrusion factors applied (in mm) - raw_extrusion = nullable_model_prop('raw_extrusion', float) + raw_extrusion = nullable_model_prop("raw_extrusion", float) # Estimated times left - times_left = model_prop('times_left', TimesLeft) + times_left = model_prop("times_left", TimesLeft) # Time needed to heat up the heaters (in s or None) - warm_up_duration = nullable_model_prop('warm_up_duration', int) + warm_up_duration = nullable_model_prop("warm_up_duration", int) def __init__(self): super().__init__() diff --git a/src/dsf/object_model/job/times_left.py b/src/dsf/object_model/job/times_left.py index 7296138..cd2411d 100644 --- a/src/dsf/object_model/job/times_left.py +++ b/src/dsf/object_model/job/times_left.py @@ -1,5 +1,3 @@ -from typing import Union - from ..model_object import ModelObject from ..utils import nullable_model_prop diff --git a/src/dsf/object_model/led_strips/__init__.py b/src/dsf/object_model/led_strips/__init__.py index afc7f3f..9654f7b 100644 --- a/src/dsf/object_model/led_strips/__init__.py +++ b/src/dsf/object_model/led_strips/__init__.py @@ -1 +1,3 @@ from .led_strip import LedStrip, LedStripType + +__all__ = ["LedStrip", "LedStripType"] diff --git a/src/dsf/object_model/led_strips/led_strip.py b/src/dsf/object_model/led_strips/led_strip.py index ea72d6e..a3c6c0c 100644 --- a/src/dsf/object_model/led_strips/led_strip.py +++ b/src/dsf/object_model/led_strips/led_strip.py @@ -23,22 +23,22 @@ class LedStrip(ModelObject): """Type of this LED strip""" # Board address of the corresponding pin - board = model_prop('board', int, 0) + board = model_prop("board", int, 0) # Order in which colour components are sent to the strip - color_order = model_prop('color_order', LedStripColorOrder, LedStripColorOrder.BGR) + color_order = model_prop("color_order", LedStripColorOrder, LedStripColorOrder.BGR) # Maximum number of LEDs that can be addressed on this strip - max_leds = model_prop('max_leds', int, 0) + max_leds = model_prop("max_leds", int, 0) # Name of the pin this LED strip is connected to - pin = model_prop('pin', str, "") + pin = model_prop("pin", str, "") # Indicates if this strip is bit-banged and therefore requires motion to be stopped before sending a command - stop_movement = model_prop('stop_movement', bool, False) + stop_movement = model_prop("stop_movement", bool, False) # Type of this LED strip - type = model_prop('type', LedStripType, LedStripType.DotStar) + type = model_prop("type", LedStripType, LedStripType.DotStar) def __init__(self): super().__init__() diff --git a/src/dsf/object_model/led_strips/led_strip_color_order.py b/src/dsf/object_model/led_strips/led_strip_color_order.py index 57334ed..abbd7b5 100644 --- a/src/dsf/object_model/led_strips/led_strip_color_order.py +++ b/src/dsf/object_model/led_strips/led_strip_color_order.py @@ -1,22 +1,23 @@ from enum import Enum + class LedStripColorOrder(int, Enum): """Color order of the LED strip""" # Default order for DotStar LEDs - BGR = 0, + BGR = (0,) # Blue, red, green - BRG = 1, + BRG = (1,) # Red, green, blue - RGB = 2, + RGB = (2,) # Red, blue, green - RBG = 3, + RBG = (3,) # Green, blue, red - GBR = 4, + GBR = (4,) # Default order for WS2812 (NeoPixel) LEDs - GRB = 5 \ No newline at end of file + GRB = 5 diff --git a/src/dsf/object_model/limits/__init__.py b/src/dsf/object_model/limits/__init__.py index 49b461d..3487401 100644 --- a/src/dsf/object_model/limits/__init__.py +++ b/src/dsf/object_model/limits/__init__.py @@ -1,3 +1,3 @@ from .limits import Limits -__all__ = ['Limits'] +__all__ = ["Limits"] diff --git a/src/dsf/object_model/messages/__init__.py b/src/dsf/object_model/messages/__init__.py index c7c15a0..d4c3838 100644 --- a/src/dsf/object_model/messages/__init__.py +++ b/src/dsf/object_model/messages/__init__.py @@ -1,3 +1,3 @@ from .messages import Message, MessageType -__all__ = ['Message', 'MessageType'] +__all__ = ["Message", "MessageType"] diff --git a/src/dsf/object_model/messages/messages.py b/src/dsf/object_model/messages/messages.py index 616087f..be3ddff 100644 --- a/src/dsf/object_model/messages/messages.py +++ b/src/dsf/object_model/messages/messages.py @@ -1,4 +1,3 @@ -import dateutil.parser as dp from datetime import datetime from enum import IntEnum diff --git a/src/dsf/object_model/model_collection.py b/src/dsf/object_model/model_collection.py index 9c9eb67..732a942 100644 --- a/src/dsf/object_model/model_collection.py +++ b/src/dsf/object_model/model_collection.py @@ -1,11 +1,9 @@ from enum import Enum -from typing import Generic, TypeVar, List, Dict, Any, Union, Optional, get_origin, get_args, cast +from typing import Callable, Generic, Sequence, TypeVar, Union, Optional, get_origin, get_args, cast from ..utils import JSONElement from .model_type import ModelType -from .model_dictionary import ModelDictionary -from .model_object import ModelObject -T = TypeVar('T') +T = TypeVar("T") # T = TypeVar('T', bound=Union[JSONElement, "ModelCollection[Any]", ModelDictionary, ModelObject]) @@ -14,8 +12,13 @@ class ModelCollection(ModelType[list[JSONElement]], Generic[T], list[T]): Class for storing model object items in a list Useful for updating model object items from JSON data (patches) """ - - def __init__(self, item_constructor: type[T] | object, value: Optional[List[T]] = None, allow_none: bool = False) -> None: + + def __init__( + self, + item_constructor: type[T] | object, + value: Optional[Sequence[T | JSONElement]] = None, + allow_none: bool = False, + ) -> None: """ :param item_constructor: Item constructor type that items must derive from :param value: Value used to initialize the list from @@ -27,10 +30,12 @@ def __init__(self, item_constructor: type[T] | object, value: Optional[List[T]] self._declared_item_constructor = item_constructor item_origin = get_origin(item_constructor) item_args = get_args(item_constructor) - self._allow_none = allow_none or (item_origin in (Union, getattr(__import__('types'), 'UnionType', Union)) and type(None) in item_args) + self._allow_none = allow_none or ( + item_origin in (Union, getattr(__import__("types"), "UnionType", Union)) and type(None) in item_args + ) resolved_constructor: object = item_constructor - if item_origin in (Union, getattr(__import__('types'), 'UnionType', Union)): + if item_origin in (Union, getattr(__import__("types"), "UnionType", Union)): non_none_args = [arg for arg in item_args if arg is not type(None)] if len(non_none_args) == 1: resolved_constructor = non_none_args[0] @@ -41,30 +46,31 @@ def __init__(self, item_constructor: type[T] | object, value: Optional[List[T]] self._runtime_model_type = cast(type[object], item_origin or item_constructor) if isinstance(resolved_constructor, type): - self._item_constructor: type[T] = cast(type[T], resolved_constructor) - elif isinstance(self._runtime_model_type, type): - self._item_constructor = cast(type[T], self._runtime_model_type) + self._item_constructor: Callable[..., T] = cast(Callable[..., T], resolved_constructor) else: - self._item_constructor = cast(type[T], object) + self._item_constructor = cast(Callable[..., T], self._runtime_model_type) if value is not None: self[:] = [] - for (index, item) in enumerate(value): + for index, item in enumerate(value): if item is None: self.append(self._coerce_item_value(item)) continue if isinstance(item, self._runtime_model_type): - self.append(item) + self.append(cast(T, item)) else: ref_item = self._create_item(index) if not is_model_object(ref_item): - raise TypeError(f"Item constructor for ModelCollection must inherit from type ModelType to update from a dict." - f" Got {type(ref_item).__name__}: {ref_item}") + raise TypeError( + f"Item constructor for ModelCollection must inherit from type ModelType" + f" to update from a dict." + f" Got {type(ref_item).__name__}: {ref_item}" + ) # if issubclass(self._item_constructor, ModelType[T]): ref_item.update_from_json(item) - self.append(ref_item) + self.append(cast(T, ref_item)) @classmethod def from_json(cls, data: list[JSONElement]): @@ -89,14 +95,16 @@ def _coerce_item_value(self, value: JSONElement) -> T: try: return self._item_constructor(value) except (TypeError, ValueError, KeyError): - raise ValueError(f"Invalid enum value {value} for collection of type {self._runtime_model_type.__name__}") + raise ValueError( + f"Invalid enum value {value} for collection of type {self._runtime_model_type.__name__}" + ) try: return self._item_constructor(value) except (TypeError, ValueError): return cast(T, value) - def update_from_json(self, data: list[JSONElement]) -> 'ModelCollection[T]': + def update_from_json(self, data: list[JSONElement]) -> "ModelCollection[T]": """ Update this instance from the given data :param json_element: JSON data to upgrade this instance from @@ -104,20 +112,21 @@ def update_from_json(self, data: list[JSONElement]) -> 'ModelCollection[T]': """ from .utils import is_model_object - if not isinstance(data, list): + # Nested JSON can reach this without type checking, so validate at runtime + if not isinstance(data, list): # pyright: ignore[reportUnnecessaryIsInstance] raise Exception(f"Invalid JSON element type for model collection {type(data)}.") # Remove deleted items - self[:] = self[:len(data)] + self[:] = self[: len(data)] # Update existing items for i in range(0, min(len(self), len(data))): current_item = self[i] new_item_data = data[i] - + # If the new item data is null, set the current item to null (even if it was a model object before) if new_item_data is None and self._allow_none: - self[i] = None + self[i] = cast(T, None) continue # If the current item is null then we need to create a new item @@ -132,12 +141,13 @@ def update_from_json(self, data: list[JSONElement]) -> 'ModelCollection[T]': ref_item = None if ref_item is not None and is_model_object(ref_item): - self[i] = cast(T, cast(ModelType[JSONElement], ref_item).update_from_json(new_item_data)) + self[i] = cast(T, ref_item.update_from_json(new_item_data)) else: self[i] = self._coerce_item_value(new_item_data) - # Use the `update_from_json` method of the current item if it's a model object, otherwise replace it with the new data + # Use the `update_from_json` method of the current item if it's a model object, + # otherwise replace it with the new data elif is_model_object(current_item): - self[i] = cast(T, cast(ModelType[JSONElement], current_item).update_from_json(new_item_data)) + self[i] = cast(T, current_item.update_from_json(new_item_data)) else: self[i] = self._coerce_item_value(new_item_data) @@ -149,16 +159,15 @@ def update_from_json(self, data: list[JSONElement]) -> 'ModelCollection[T]': elif isinstance(item_to_add, self._runtime_model_type): self.append(cast(T, item_to_add)) else: - ref_item: Optional[T] = None + ref_item = None try: ref_item = self._create_item(i) except TypeError: ref_item = None if ref_item is not None and is_model_object(ref_item): - self.append(cast(ModelType[JSONElement], ref_item).update_from_json(item_to_add)) + self.append(cast(T, ref_item.update_from_json(item_to_add))) else: self.append(self._coerce_item_value(item_to_add)) - return self diff --git a/src/dsf/object_model/model_dictionary.py b/src/dsf/object_model/model_dictionary.py index 8e0a3bf..9019343 100644 --- a/src/dsf/object_model/model_dictionary.py +++ b/src/dsf/object_model/model_dictionary.py @@ -1,19 +1,24 @@ -from typing import Optional, TypeVar, Self +from typing import Any, Optional, Self, TYPE_CHECKING, cast -from .model_object import TModelObject from .model_type import ModelType -from ..utils import JSONObj, JSONElement, preserve_builtin +from ..utils import JSONObj -T = TypeVar("T") +if TYPE_CHECKING: + from .model_object import ModelObject -class ModelDictionary(ModelType[JSONObj], JSONObj): +class ModelDictionary(ModelType[JSONObj], dict[str, Any]): """ Class for storing model object items in a dictionary Useful for updating model object items from JSON data (patches) """ - def __init__(self, null_deletes_keys: bool, item_constructor: Optional[type[TModelObject]] = None, value: Optional[JSONObj | TModelObject] = None): + def __init__( + self, + null_deletes_keys: bool, + item_constructor: Optional[type["ModelObject"]] = None, + value: Optional[dict[str, Any]] = None, + ): """ :param null_deletes_keys: Whether setting null to items effectively deletes them :param item_constructor: Item constructor type to use for type-checking @@ -24,44 +29,38 @@ def __init__(self, null_deletes_keys: bool, item_constructor: Optional[type[TMod self._null_deletes_keys = null_deletes_keys if value is not None: - if not isinstance(value, dict): - raise TypeError(f"value must be of type dict or None. Got {type(value)}: {value}") for k, v in value.items(): self[k] = v - def __setitem__(self, key: str, value: JSONElement) -> Self | None: + def __setitem__(self, key: str, value: Any) -> None: from .utils import is_model_object if value is None: if self._null_deletes_keys: self.pop(key, None) - return self + return return super().__setitem__(key, value) current_item = self.get(key) if current_item is None and self._item_constructor: + if not isinstance(value, dict): + raise TypeError( + f"Value for key '{key}' must be of type dict to update the model object." + f" Got {type(value)}: {value}" + ) new_item = self._item_constructor() - if is_model_object(new_item): - if not isinstance(value, dict): - raise TypeError(f"Value for key '{key}' must be of type dict to update the model object. Got {type(value)}: {value}") - updated_item = new_item.update_from_json(value) - return super().__setitem__(key, updated_item) - else: - ref_item = self._item_constructor() - new_item = ref_item.update_from_json(value) - return super().__setitem__(key, new_item) + return super().__setitem__(key, new_item.update_from_json(cast(JSONObj, value))) elif is_model_object(current_item): - new_item = current_item.update_from_json(value) - return super().__setitem__(key, new_item) + return super().__setitem__(key, current_item.update_from_json(value)) return super().__setitem__(key, value) @classmethod - def from_json(cls, data: JSONObj): + def from_json(cls, data: Optional[JSONObj]) -> Self: """Deserialize a new instance of this class from JSON deserialized dictionary""" - return cls().update_from_json(**preserve_builtin(data)) + return cls(False).update_from_json(data) - def update_from_json(self, data: JSONObj): + def update_from_json(self, data: Optional[JSONObj]) -> Self: if data is None: super().clear() else: diff --git a/src/dsf/object_model/model_object.py b/src/dsf/object_model/model_object.py index 72c7aae..bd9f218 100644 --- a/src/dsf/object_model/model_object.py +++ b/src/dsf/object_model/model_object.py @@ -3,13 +3,13 @@ from typing import TypeVar, Any, Union, cast from .model_type import ModelType -from ..utils import preserve_builtin, camel_to_snake, snake_to_camel, JSONObj +from ..utils import preserve_builtin, camel_to_snake, snake_to_camel, JSONElement, JSONObj class FloatJSON(float): # Remove trailing zeros from float numbers def __repr__(self) -> str: - return f'{self:g}' + return f"{self:g}" _encoder = cast(Any, json.encoder) @@ -38,7 +38,7 @@ def __json_serialize(obj: object) -> str | JSONObj: if isinstance(obj, datetime): return obj.isoformat() if isinstance(obj, float): - return f'{obj:g}' + return f"{obj:g}" if isinstance(obj, SbcPermissions): return obj.name if isinstance(obj, DriverId): @@ -46,9 +46,9 @@ def __json_serialize(obj: object) -> str | JSONObj: # Convert snake_case class attributes into CamelCase JSON style # also convert back 'globals' to 'global' - return {snake_to_camel(str(k) if k != '_globals' else '_global'): v for k, v in obj.__dict__.items()} + return {snake_to_camel(str(k) if k != "_globals" else "_global"): v for k, v in obj.__dict__.items()} - def _update_from_json(self: TModelObject, **kwargs: object) -> TModelObject: + def _update_from_json(self: TModelObject, **kwargs: JSONElement) -> TModelObject: """Update this instance from a given JSON element This method iterate over all writeable properties to update them. It means classes with get-only properties should override this method in order to update them. @@ -58,13 +58,14 @@ def _update_from_json(self: TModelObject, **kwargs: object) -> TModelObject: # Get the class writeable properties including from inherited classes # (the ones which have a setter -> fset property object attribute) cls_dict = {attr: getattr(self.__class__, attr) for attr in dir(self.__class__)} - writeable_properties = [attr for attr, value in cls_dict.items() - if isinstance(value, property) and value.fset is not None] + writeable_properties = [ + attr for attr, value in cls_dict.items() if isinstance(value, property) and value.fset is not None + ] instance_attributes = vars(self) for json_key, json_value in kwargs.items(): # Convert JSON attributes from CamelCase to snake_case to satisfy python PEP8 naming # Remove trailing underscore set by preserve_builtin() - json_key_snake = camel_to_snake(json_key.rstrip('_')) + json_key_snake = camel_to_snake(json_key.rstrip("_")) # Write public attributes by using their setter property if json_key_snake in writeable_properties: attr = getattr(self, json_key_snake) @@ -102,4 +103,3 @@ def update_from_json(self: TModelObject, data: Union[JSONObj, str]) -> TModelObj def to_json(self) -> str: """Serialize this instance of this class into a JSON dictionary""" return self.__str__() - diff --git a/src/dsf/object_model/model_type.py b/src/dsf/object_model/model_type.py index 42356f4..7766226 100644 --- a/src/dsf/object_model/model_type.py +++ b/src/dsf/object_model/model_type.py @@ -2,13 +2,14 @@ from ..utils import JSONElement -T = TypeVar("T", bound=JSONElement) +T = TypeVar("T", bound=JSONElement, contravariant=True) TModelValue: TypeAlias = "ModelType[T]" + @runtime_checkable class ModelType(Protocol[T]): @classmethod - def from_json(cls: type[T], data: T) -> T: + def from_json(cls, data: T) -> Self: """Deserialize a new instance of this class from JSON deserialized dictionary""" ... diff --git a/src/dsf/object_model/move/__init__.py b/src/dsf/object_model/move/__init__.py index 11acb2d..206bb0c 100644 --- a/src/dsf/object_model/move/__init__.py +++ b/src/dsf/object_model/move/__init__.py @@ -15,3 +15,26 @@ from .move_segmentation import MoveSegmentation from .probe_grid import ProbeGrid from .skew import Skew + +__all__ = [ + "Axis", + "AxisLetter", + "CurrentMove", + "DriverId", + "Extruder", + "ExtruderNonlinear", + "InputShaping", + "InputShapingType", + "MicroStepping", + "MotorsIdleControl", + "Move", + "MoveCalibration", + "MoveCompensation", + "MoveCompensationType", + "MoveDeviations", + "MoveQueueItem", + "MoveRotation", + "MoveSegmentation", + "ProbeGrid", + "Skew", +] diff --git a/src/dsf/object_model/move/axis.py b/src/dsf/object_model/move/axis.py index 6130fc7..e750f11 100644 --- a/src/dsf/object_model/move/axis.py +++ b/src/dsf/object_model/move/axis.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import List, Optional from .driver_id import DriverId from .microstepping import MicroStepping @@ -11,43 +10,43 @@ class AxisLetter(str, Enum): """List of supported axis letters""" - X = 'X' - Y = 'Y' - Z = 'Z' - U = 'U' - V = 'V' - W = 'W' - A = 'A' - B = 'B' - C = 'C' - D = 'D' - a = 'a' - b = 'b' - c = 'c' - d = 'd' - e = 'e' - f = 'f' - g = 'g' - h = 'h' - i = 'i' - j = 'j' - k = 'k' - l = 'l' - m = 'm' - n = 'n' - o = 'o' - p = 'p' - q = 'q' - r = 'r' - s = 's' - t = 't' - u = 'u' - v = 'v' - w = 'w' - x = 'x' - y = 'y' - z = 'z' - none = '' + X = "X" + Y = "Y" + Z = "Z" + U = "U" + V = "V" + W = "W" + A = "A" + B = "B" + C = "C" + D = "D" + a = "a" + b = "b" + c = "c" + d = "d" + e = "e" + f = "f" + g = "g" + h = "h" + i = "i" + j = "j" + k = "k" + l = "l" # noqa: E741 + m = "m" + n = "n" + o = "o" + p = "p" + q = "q" + r = "r" + s = "s" + t = "t" + u = "u" + v = "v" + w = "w" + x = "x" + y = "y" + z = "z" + none = "" class Axis(ModelObject): @@ -129,4 +128,4 @@ class Axis(ModelObject): workplace_offsets = model_prop("workplace_offsets", ModelCollection[float], ModelCollection(float)) def __init__(self): - super().__init__() \ No newline at end of file + super().__init__() diff --git a/src/dsf/object_model/move/driver_id.py b/src/dsf/object_model/move/driver_id.py index c9d4332..fa2deff 100644 --- a/src/dsf/object_model/move/driver_id.py +++ b/src/dsf/object_model/move/driver_id.py @@ -1,9 +1,10 @@ import re -from typing import Optional +from typing import Optional, Self from ..model_object import ModelObject from ...exceptions import CodeParserException +from ...utils import JSONObj def is_driverId(value: object) -> bool: @@ -20,13 +21,17 @@ class DriverId(ModelObject): board: int port: int - def __init__(self, as_str: Optional[str] = None, as_int: Optional[int] = None, board: Optional[int] = None, port: Optional[int] = None): + def __init__( + self, + as_str: Optional[str] = None, + as_int: Optional[int] = None, + board: Optional[int] = None, + port: Optional[int] = None, + ): super().__init__() - if board is not None: - self.board = board - if port is not None: - self.port = port + self.board = board if board is not None else 0 + self.port = port if port is not None else 0 if as_int is not None: if as_int < 0: @@ -53,26 +58,26 @@ def as_int(self) -> int: def __str__(self, **kwargs: object): """Convert this instance to a string""" - return f"{self.port}" if self.board is None else f"{self.board}.{self.port}" + return f"{self.board}.{self.port}" - def __eq__(self, o): + def __eq__(self, o: object) -> bool: """Checks whether this instance is equal to another""" - if self is None: - return o is None return isinstance(o, DriverId) and self.board == o.board and self.port == o.port - def __ne__(self, o): + def __ne__(self, o: object) -> bool: return not self == o - def update_from_json(self, data): - if isinstance(data, str): - matches = re.search(r'(\d+)\.(\d+)', data) - if matches: - self.board = int(matches.group(1)) - self.port = int(matches.group(2)) - else: - self.board = None - self.port = int(data) - return self - return None - + def __hash__(self) -> int: + return hash((self.board, self.port)) + + def update_from_json(self, data: JSONObj | str) -> Self: + if not isinstance(data, str): + raise TypeError(f"DriverId must be updated from a string. Got {type(data).__name__}: {data}") + matches = re.search(r"(\d+)\.(\d+)", data) + if matches: + self.board = int(matches.group(1)) + self.port = int(matches.group(2)) + else: + self.board = 0 + self.port = int(data) + return self diff --git a/src/dsf/object_model/move/extruder.py b/src/dsf/object_model/move/extruder.py index ef3ed5c..deeca15 100644 --- a/src/dsf/object_model/move/extruder.py +++ b/src/dsf/object_model/move/extruder.py @@ -16,7 +16,7 @@ class Extruder(ModelObject): current = model_prop("current", int, 0) # Assigned driver - driver = nullable_model_prop('driver', DriverId) + driver = nullable_model_prop("driver", DriverId) # Extrusion factor to use (0..1 or greater) factor = model_prop("factor", float, 1.0) @@ -49,11 +49,11 @@ class Extruder(ModelObject): position = model_prop("position", float, 0.0) # Pressure advance parameters (see M572) - press_adv = model_prop('press_adv', ExtruderPressureAdvance) + press_adv = model_prop("press_adv", ExtruderPressureAdvance) # Pressure advance # Obsolete: use press_adv.k0 instead - pressure_advance = model_prop('pressure_advance', float, 0) + pressure_advance = model_prop("pressure_advance", float, 0) # Motor jerk during the current print only (in mm/s) printing_jerk = model_prop("printing_jerk", float, 15.0) diff --git a/src/dsf/object_model/move/extruder_pressure_advance.py b/src/dsf/object_model/move/extruder_pressure_advance.py index 8bb0196..975331b 100644 --- a/src/dsf/object_model/move/extruder_pressure_advance.py +++ b/src/dsf/object_model/move/extruder_pressure_advance.py @@ -5,7 +5,8 @@ class ExtruderPressureAdvance(ModelObject): """Information about an extruder drive""" - # Delay coefficient (in ms), or null if pressure advance is in simple mode (k0 = 0) - RRF reports infinity here as null + # Delay coefficient (in ms), or null if pressure advance is in simple mode (k0 = 0) + # RRF reports infinity here as null d = nullable_model_prop("d", float) # K0 coefficient @@ -13,6 +14,6 @@ class ExtruderPressureAdvance(ModelObject): # K1 coeffient k1 = model_prop("k1", float, 0.0) - + def __init__(self): super().__init__() diff --git a/src/dsf/object_model/move/input_shaping.py b/src/dsf/object_model/move/input_shaping.py index 2bfc8c7..14e1000 100644 --- a/src/dsf/object_model/move/input_shaping.py +++ b/src/dsf/object_model/move/input_shaping.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import List from ..model_object import ModelObject @@ -45,7 +44,7 @@ def _missing_(cls, value: object): class InputShaping(ModelObject): - """Parameters describing input shaping """ + """Parameters describing input shaping""" # Amplitudes of the input shaper amplitudes = model_prop("amplitudes", ModelCollection[float], ModelCollection(float)) @@ -62,11 +61,12 @@ class InputShaping(ModelObject): # Frequency (in Hz) frequency = model_prop("frequency", float, 40.0) - # Minimum fraction of the original acceleration or feed rate to which the acceleration or feed rate may be reduced in order to apply input shaping + # Minimum fraction of the original acceleration or feed rate to which the acceleration or feed rate + # may be reduced in order to apply input shaping reduction_limit = model_prop("reduction_limit", float, 0.25) # Configured input shaping type type = model_prop("type", InputShapingType, InputShapingType.none) def __init__(self): - super().__init__() \ No newline at end of file + super().__init__() diff --git a/src/dsf/object_model/move/keepout_zone.py b/src/dsf/object_model/move/keepout_zone.py index f51280a..9da8904 100644 --- a/src/dsf/object_model/move/keepout_zone.py +++ b/src/dsf/object_model/move/keepout_zone.py @@ -1,5 +1,3 @@ -from typing import List - from ..model_collection import ModelCollection from ..model_object import ModelObject from ..utils import model_prop diff --git a/src/dsf/object_model/move/kinematics/__init__.py b/src/dsf/object_model/move/kinematics/__init__.py index 4f4adc0..240c24e 100644 --- a/src/dsf/object_model/move/kinematics/__init__.py +++ b/src/dsf/object_model/move/kinematics/__init__.py @@ -9,5 +9,15 @@ from .tilt_correction import TiltCorrection from .zleadscrew_kinematics import ZLeadscrewKinematics -__all__ = ["CoreKinematics", "DeltaKinematics", "DeltaTower", "HangprinterKinematics", "Kinematics", - "KinematicsName", "PolarKinematics", "ScaraKinematics", "TiltCorrection", "ZLeadscrewKinematics"] +__all__ = [ + "CoreKinematics", + "DeltaKinematics", + "DeltaTower", + "HangprinterKinematics", + "Kinematics", + "KinematicsName", + "PolarKinematics", + "ScaraKinematics", + "TiltCorrection", + "ZLeadscrewKinematics", +] diff --git a/src/dsf/object_model/move/kinematics/core_kinematics.py b/src/dsf/object_model/move/kinematics/core_kinematics.py index 06bcb29..7535b7d 100644 --- a/src/dsf/object_model/move/kinematics/core_kinematics.py +++ b/src/dsf/object_model/move/kinematics/core_kinematics.py @@ -5,16 +5,8 @@ class CoreKinematics(ZLeadscrewKinematics): - forward_matrix = model_prop("forward_matrix", list[list[float]], [ - [1, 0, 0], - [0, 1, 0], - [0, 0, 1] - ]) - inverse_matrix = model_prop("inverse_matrix", list[list[float]], [ - [1, 0, 0], - [0, 1, 0], - [0, 0, 1] - ]) + forward_matrix = model_prop("forward_matrix", list[list[float]], [[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + inverse_matrix = model_prop("inverse_matrix", list[list[float]], [[1, 0, 0], [0, 1, 0], [0, 0, 1]]) def __init__(self, name: KinematicsName = KinematicsName.cartesian): super(CoreKinematics, self).__init__(name) diff --git a/src/dsf/object_model/move/kinematics/delta_kinematics.py b/src/dsf/object_model/move/kinematics/delta_kinematics.py index 22240d4..2834912 100644 --- a/src/dsf/object_model/move/kinematics/delta_kinematics.py +++ b/src/dsf/object_model/move/kinematics/delta_kinematics.py @@ -1,5 +1,3 @@ -from typing import List - from .delta_tower import DeltaTower from .kinematics import Kinematics from .kinematics_name import KinematicsName diff --git a/src/dsf/object_model/move/kinematics/hangprinter_kinematics.py b/src/dsf/object_model/move/kinematics/hangprinter_kinematics.py index 7edf353..09b6a91 100644 --- a/src/dsf/object_model/move/kinematics/hangprinter_kinematics.py +++ b/src/dsf/object_model/move/kinematics/hangprinter_kinematics.py @@ -1,5 +1,3 @@ -from typing import List - from .kinematics import Kinematics from .kinematics_name import KinematicsName from ...utils import model_prop @@ -8,15 +6,11 @@ class HangprinterKinematics(Kinematics): """Information about hangprinter kinematics""" - anchors = model_prop("anchors", list[list[float]], [ - [0, -2000, -100], - [2000, 1000, -100], - [-2000, 1000, -100], - [0, 0, 3000] - ]) + anchors = model_prop( + "anchors", list[list[float]], [[0, -2000, -100], [2000, 1000, -100], [-2000, 1000, -100], [0, 0, 3000]] + ) print_radius = model_prop("print_radius", float, 1500) def __init__(self): super(HangprinterKinematics, self).__init__() self.name = KinematicsName.hangprinter - diff --git a/src/dsf/object_model/move/kinematics/kinematics.py b/src/dsf/object_model/move/kinematics/kinematics.py index bdb7db5..9ca78e8 100644 --- a/src/dsf/object_model/move/kinematics/kinematics.py +++ b/src/dsf/object_model/move/kinematics/kinematics.py @@ -1,9 +1,8 @@ -from typing import Union - from .kinematics_name import KinematicsName from ..move_segmentation import MoveSegmentation from ...model_object import ModelObject from ...utils import model_prop, nullable_model_prop +from ....utils import JSONElement class Kinematics(ModelObject): @@ -18,21 +17,19 @@ def __init__(self, name: KinematicsName = KinematicsName.unknown): self._segmentation = None @staticmethod - def get_kinematics_type(name: KinematicsName | str): - from .core_kinematics import CoreKinematics - from .delta_kinematics import DeltaKinematics - from .hangprinter_kinematics import HangprinterKinematics - from .polar_kinematics import PolarKinematics - from .scara_kinematics import ScaraKinematics + def get_kinematics_type(name: KinematicsName | str) -> "Kinematics": """ Figure out the required type for the given kinematics name :param name: Kinematics name :returns: Required type """ - if isinstance(name, str): - name = KinematicsName(name.lower().replace(' ', '')) - elif not isinstance(name, KinematicsName): - raise TypeError(f'{__name__} must be KinematicsName. Got {type(name)}: {name}') + from .core_kinematics import CoreKinematics + from .delta_kinematics import DeltaKinematics + from .hangprinter_kinematics import HangprinterKinematics + from .polar_kinematics import PolarKinematics + from .scara_kinematics import ScaraKinematics + + name = KinematicsName(name.lower().replace(" ", "")) if name in [ KinematicsName.cartesian, @@ -40,7 +37,7 @@ def get_kinematics_type(name: KinematicsName | str): KinematicsName.coreXYU, KinematicsName.coreXYUV, KinematicsName.coreXZ, - KinematicsName.markForged + KinematicsName.markForged, ]: return CoreKinematics(name) elif name == KinematicsName.linearDelta: @@ -53,17 +50,17 @@ def get_kinematics_type(name: KinematicsName | str): return ScaraKinematics(name) elif name == KinematicsName.polar: return PolarKinematics() - return name + return Kinematics(name) - def _update_from_json(self, **kwargs): + def _update_from_json(self, **kwargs: JSONElement) -> "Kinematics": """Override ObjectModel._update_from_json to return the Kinematics type matching the given name""" - if 'name' in kwargs: - kwargs['name'] = KinematicsName(kwargs.get('name').lower().replace(' ', '')) + name = kwargs.get("name") + if isinstance(name, str): + kinematics_name = KinematicsName(name.lower().replace(" ", "")) + kwargs["name"] = kinematics_name - if self.name != kwargs.get('name'): - kinematic_type = self.get_kinematics_type(kwargs.get('name')) - new_kinematic = kinematic_type.update_from_json(kwargs) - return new_kinematic + if self.name != kinematics_name: + return self.get_kinematics_type(kinematics_name).update_from_json(kwargs) super(Kinematics, self)._update_from_json(**kwargs) return self diff --git a/src/dsf/object_model/move/kinematics/polar_kinematics.py b/src/dsf/object_model/move/kinematics/polar_kinematics.py index c704109..34d9dba 100644 --- a/src/dsf/object_model/move/kinematics/polar_kinematics.py +++ b/src/dsf/object_model/move/kinematics/polar_kinematics.py @@ -11,7 +11,7 @@ class PolarKinematics(Kinematics): # Homed radius (in mm) radius_homed = model_prop("radius_homed", float, 0.0) - #Maximum radius (in mm) + # Maximum radius (in mm) radius_max = model_prop("radius_max", float, 0.0) # Minimum radius (in mm) @@ -26,4 +26,3 @@ class PolarKinematics(Kinematics): def __init__(self): super(PolarKinematics, self).__init__() self._name = KinematicsName.polar - diff --git a/src/dsf/object_model/move/kinematics/scara_kinematics.py b/src/dsf/object_model/move/kinematics/scara_kinematics.py index d08e4c9..339bbe4 100644 --- a/src/dsf/object_model/move/kinematics/scara_kinematics.py +++ b/src/dsf/object_model/move/kinematics/scara_kinematics.py @@ -35,4 +35,3 @@ class ScaraKinematics(ZLeadscrewKinematics): def __init__(self, name: KinematicsName): super(ScaraKinematics, self).__init__(name) - diff --git a/src/dsf/object_model/move/kinematics/tilt_correction.py b/src/dsf/object_model/move/kinematics/tilt_correction.py index 398dd38..62af3da 100644 --- a/src/dsf/object_model/move/kinematics/tilt_correction.py +++ b/src/dsf/object_model/move/kinematics/tilt_correction.py @@ -1,5 +1,3 @@ -from typing import List - from ...model_object import ModelObject from ...model_collection import ModelCollection from ...utils import model_prop diff --git a/src/dsf/object_model/move/motion_system.py b/src/dsf/object_model/move/motion_system.py index aa5ddf8..c992419 100644 --- a/src/dsf/object_model/move/motion_system.py +++ b/src/dsf/object_model/move/motion_system.py @@ -6,6 +6,7 @@ from .move_rotation import MoveRotation from ..state.restore_point import RestorePoint + class MotionSystem(ModelObject): """Information about a motion system""" diff --git a/src/dsf/object_model/move/move.py b/src/dsf/object_model/move/move.py index bba6409..e7a74b6 100644 --- a/src/dsf/object_model/move/move.py +++ b/src/dsf/object_model/move/move.py @@ -1,5 +1,3 @@ -from typing import List - from .axis import Axis from .current_move import CurrentMove from .extruder import Extruder @@ -21,79 +19,79 @@ class Move(ModelObject): """Information about the move subsystem""" # Value of the M201 T parameter. Only present in builds that support S-curve acceleration - acceleration_time = nullable_model_prop('acceleration_time', float) + acceleration_time = nullable_model_prop("acceleration_time", float) # List of the configured axes - axes = model_prop('axes', ModelCollection[Axis], ModelCollection(Axis)) + axes = model_prop("axes", ModelCollection[Axis], ModelCollection(Axis)) # Backlash distance multiplier - backlash_factor = model_prop('backlash_factor', int, 10) + backlash_factor = model_prop("backlash_factor", int, 10) # Information about the automatic calibration - calibration = model_prop('calibration', MoveCalibration) + calibration = model_prop("calibration", MoveCalibration) # Information about the currently configured compensation options - compensation = model_prop('compensation', MoveCompensation) + compensation = model_prop("compensation", MoveCompensation) # Information about the current move - current_move = model_prop('current_move', CurrentMove) + current_move = model_prop("current_move", CurrentMove) # List of configured extruders - extruders = model_prop('extruders', ModelCollection[Extruder], ModelCollection(Extruder)) + extruders = model_prop("extruders", ModelCollection[Extruder], ModelCollection(Extruder)) # Idle current reduction parameters - idle = model_prop('idle', MotorsIdleControl) + idle = model_prop("idle", MotorsIdleControl) # List of configured keep-out zones - keepout = model_prop('keepout', ModelCollection[KeepoutZone], ModelCollection(KeepoutZone)) + keepout = model_prop("keepout", ModelCollection[KeepoutZone], ModelCollection(KeepoutZone)) # Configured kinematics options - kinematics = model_prop('kinematics', Kinematics) + kinematics = model_prop("kinematics", Kinematics) # Limit axis positions by their minima and maxima - limit_axes = model_prop('limit_axes', bool, True) + limit_axes = model_prop("limit_axes", bool, True) # Minimum allowed movement speed (in mm/min) - min_speed = model_prop('min_speed', float, 30) + min_speed = model_prop("min_speed", float, 30) # Indicates if standard moves are forbidden if the corresponding axis is not homed - no_moves_before_homing = model_prop('no_moves_before_homing', bool, True) + no_moves_before_homing = model_prop("no_moves_before_homing", bool, True) # List of configured motion systems - motion_systems = model_prop('motion_systems', ModelCollection[MotionSystem], ModelCollection(MotionSystem)) + motion_systems = model_prop("motion_systems", ModelCollection[MotionSystem], ModelCollection(MotionSystem)) # Maximum acceleration allowed while printing (in mm/s^2) # deprecated, use motion_systems[].printing_acceleration instead - printing_acceleration = model_prop('printing_acceleration', float, 10000) + printing_acceleration = model_prop("printing_acceleration", float, 10000) # List of move queue items (DDA rings) - queue = model_prop('queue', ModelCollection[MoveQueueItem], ModelCollection(MoveQueueItem)) + queue = model_prop("queue", ModelCollection[MoveQueueItem], ModelCollection(MoveQueueItem)) # Parameters for centre rotation # deprecated, use motion_systems[].rotation instead - rotation = model_prop('rotation', MoveRotation) + rotation = model_prop("rotation", MoveRotation) # Parameters for input shaping - shaping = model_prop('shaping', InputShaping) + shaping = model_prop("shaping", InputShaping) # Speed factor applied to every regular move (0.01..1 or greater) - speed_factor = model_prop('speed_factor', float, 1) + speed_factor = model_prop("speed_factor", float, 1) # Maximum acceleration allowed while travelling (in mm/s^2) # deprecated, use motion_systems[].travel_acceleration instead - travel_acceleration = model_prop('travel_acceleration', float, 10000) + travel_acceleration = model_prop("travel_acceleration", float, 10000) # Indicates if third-order S-curve acceleration is enabled. # Only present in builds that support S-curve acceleration - using_S_curve = model_prop('using_S_curve', bool, False) + using_S_curve = model_prop("using_S_curve", bool, False) # Virtual total extruder position # deprecated, use motion_systems[].virtual_e_pos instead - virtual_e_pos = model_prop('virtual_e_pos', float, 0) + virtual_e_pos = model_prop("virtual_e_pos", float, 0) # Index of the currently selected workplace # deprecated, use motion_systems[].workplace_number instead - workplace_number = model_prop('workplace_number', int, 0) + workplace_number = model_prop("workplace_number", int, 0) def __init__(self): - super().__init__() \ No newline at end of file + super().__init__() diff --git a/src/dsf/object_model/move/move_compensation.py b/src/dsf/object_model/move/move_compensation.py index a98b6c0..a21b8df 100644 --- a/src/dsf/object_model/move/move_compensation.py +++ b/src/dsf/object_model/move/move_compensation.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import Union from .move_calibration import MoveDeviations from .probe_grid import ProbeGrid @@ -22,25 +21,25 @@ class MoveCompensation(ModelObject): """Information about the configured compensation options""" # Effective height before the bed compensation is turned off (in mm) or null if not configured - fade_height = nullable_model_prop('fade_height', float) - + fade_height = nullable_model_prop("fade_height", float) + # Full path to the currently used height map file or null if none is in use - file = nullable_model_prop('file', str) + file = nullable_model_prop("file", str) # Grid settings of the loaded heightmap or null if no heightmap is loaded - live_grid = nullable_model_prop('live_grid', ProbeGrid) + live_grid = nullable_model_prop("live_grid", ProbeGrid) # Deviations of the mesh grid or null if not applicable - mesh_deviation = nullable_model_prop('mesh_deviation', MoveDeviations) + mesh_deviation = nullable_model_prop("mesh_deviation", MoveDeviations) # Probe grid settings as defined by M557 - probe_grid = model_prop('probe_grid', ProbeGrid) + probe_grid = model_prop("probe_grid", ProbeGrid) # Information about the configured orthogonal axis parameters - skew = model_prop('skew', Skew) + skew = model_prop("skew", Skew) # Type of the compensation in use - type = model_prop('type', MoveCompensationType, MoveCompensationType.none) + type = model_prop("type", MoveCompensationType, MoveCompensationType.none) def __init__(self): super().__init__() diff --git a/src/dsf/object_model/move/probe_grid.py b/src/dsf/object_model/move/probe_grid.py index 20d84d4..8d660ff 100644 --- a/src/dsf/object_model/move/probe_grid.py +++ b/src/dsf/object_model/move/probe_grid.py @@ -7,7 +7,7 @@ class ProbeGrid(ModelObject): """Information about the configured probe grid (see M557)""" # Axis letters of this heightmap - axes = model_prop("axes", ModelCollection[str], ModelCollection(str, ['X', 'Y'])) + axes = model_prop("axes", ModelCollection[str], ModelCollection(str, ["X", "Y"])) # End coordinates of the heightmap maxs = model_prop("maxs", ModelCollection[float], ModelCollection(float, [-1.0, -1.0])) diff --git a/src/dsf/object_model/network/__init__.py b/src/dsf/object_model/network/__init__.py index 05c50dc..51259dd 100644 --- a/src/dsf/object_model/network/__init__.py +++ b/src/dsf/object_model/network/__init__.py @@ -4,4 +4,4 @@ from .network_protocol import NetworkProtocol from .network_state import NetworkState -__all__ = ['Network', 'NetworkInterface', 'NetworkInterfaceType', 'NetworkProtocol', 'NetworkState'] +__all__ = ["Network", "NetworkInterface", "NetworkInterfaceType", "NetworkProtocol", "NetworkState"] diff --git a/src/dsf/object_model/network/network_interface.py b/src/dsf/object_model/network/network_interface.py index 270179a..83d9c9b 100644 --- a/src/dsf/object_model/network/network_interface.py +++ b/src/dsf/object_model/network/network_interface.py @@ -1,57 +1,56 @@ -from typing import List, Union - from .network_interface_type import NetworkInterfaceType from .network_protocol import NetworkProtocol from .network_state import NetworkState from ..model_object import ModelObject from ..model_collection import ModelCollection from ..utils import nullable_model_prop, model_prop -from ...utils import deprecated class NetworkInterface(ModelObject): """Information about a network interface""" # List of active protocols - active_protocols = model_prop('active_protocols', ModelCollection[NetworkProtocol], ModelCollection(NetworkProtocol)) + active_protocols = model_prop( + "active_protocols", ModelCollection[NetworkProtocol], ModelCollection(NetworkProtocol) + ) # Actual IPv4 address of the network adapter or null if unknown - actual_IP = nullable_model_prop('actual_IP', str) - + actual_IP = nullable_model_prop("actual_IP", str) + # Configured IPv4 address of the network adapter or null if unknown - configured_IP = nullable_model_prop('configured_IP', str) - + configured_IP = nullable_model_prop("configured_IP", str) + # Configured IPv4 DNS server fo the network adapter or null if unknown - dns_server = nullable_model_prop('dns_server', str) - - firmware_version = nullable_model_prop('firmware_version', str) + dns_server = nullable_model_prop("dns_server", str) + + firmware_version = nullable_model_prop("firmware_version", str) # IPv4 gateway of the network adapter or null if unknown - gateway = nullable_model_prop('gateway', str) + gateway = nullable_model_prop("gateway", str) # Physical address of the network adapter or null if unknown - mac = nullable_model_prop('mac', str) + mac = nullable_model_prop("mac", str) # Received signal strength indicator of the WiFi adapter (only WiFi, in dBm, or null if unknown) - rssi = nullable_model_prop('rssi', int) + rssi = nullable_model_prop("rssi", int) # Speed of the network interface (in MBit, null if unknown, 0 if not connected) - speed = nullable_model_prop('speed', int) + speed = nullable_model_prop("speed", int) # SSID of the Wi-Fi network or null if not applicable - ssid = nullable_model_prop('ssid', str) + ssid = nullable_model_prop("ssid", str) # State of this network interface or null if unknown - state = nullable_model_prop('state', NetworkState, constructor=lambda: None) + state = nullable_model_prop("state", NetworkState, constructor=lambda: None) # Subnet of the network adapter or null if unknown - subnet = nullable_model_prop('subnet', str) + subnet = nullable_model_prop("subnet", str) # Type of this network interface - type = model_prop('type', NetworkInterfaceType, NetworkInterfaceType.wifi) + type = model_prop("type", NetworkInterfaceType, NetworkInterfaceType.wifi) # WiFi country code if this is a WiFi adapter and if the country code can be determined - wifi_country = nullable_model_prop('wifi_country', str) + wifi_country = nullable_model_prop("wifi_country", str) def __init__(self): super().__init__() diff --git a/src/dsf/object_model/object_model.py b/src/dsf/object_model/object_model.py index 26b0f2f..36d710f 100644 --- a/src/dsf/object_model/object_model.py +++ b/src/dsf/object_model/object_model.py @@ -1,8 +1,9 @@ -from typing import List, Optional +from typing import Optional from .model_collection import ModelCollection from .model_dictionary import ModelDictionary from .model_object import ModelObject +from ..utils import JSONElement from .boards import Boards from .directories import Directories from .fans import Fan @@ -29,34 +30,36 @@ class ObjectModel(ModelObject): # Information about the SBC which Duet Software Framework is running on. # This is None if the system is operating in standalone mode - boards = model_prop('boards', Boards, Boards()) - directories = model_prop('directories', Directories) - fans = model_prop('fans', ModelCollection[Optional[Fan]], ModelCollection(Optional[Fan])) - globals = model_prop('globals', ModelDictionary, ModelDictionary(False)) - heat = model_prop('heat', Heat) - inputs = model_prop('inputs', Inputs) - job = model_prop('job', Job) - led_strips = model_prop('led_strips', ModelCollection[LedStrip], ModelCollection(LedStrip)) - limits = model_prop('limits', Limits) - messages = model_prop('messages', ModelCollection[Message], ModelCollection(Message)) - move = model_prop('move', Move) - network = model_prop('network', Network) - plugins = model_prop('plugins', ModelDictionary, ModelDictionary(True, Plugin)) - sbc = nullable_model_prop('sbc', SBC) - sensors = model_prop('sensors', Sensors) - spindles = model_prop('spindles', ModelCollection[Optional[Spindle]], ModelCollection(Optional[Spindle])) - state = model_prop('state', State) - tools = model_prop('tools', ModelCollection[Optional[Tool]], ModelCollection(Optional[Tool])) - volumes = model_prop('volumes', ModelCollection[Volume], ModelCollection(Volume)) - + boards = model_prop("boards", Boards, Boards()) + directories = model_prop("directories", Directories) + fans = model_prop("fans", ModelCollection[Optional[Fan]], ModelCollection(Optional[Fan])) + globals = model_prop("globals", ModelDictionary, ModelDictionary(False)) + heat = model_prop("heat", Heat) + inputs = model_prop("inputs", Inputs) + job = model_prop("job", Job) + led_strips = model_prop("led_strips", ModelCollection[LedStrip], ModelCollection(LedStrip)) + limits = model_prop("limits", Limits) + messages = model_prop("messages", ModelCollection[Message], ModelCollection(Message)) + move = model_prop("move", Move) + network = model_prop("network", Network) + plugins = model_prop("plugins", ModelDictionary, ModelDictionary(True, Plugin)) + sbc = nullable_model_prop("sbc", SBC) + sensors = model_prop("sensors", Sensors) + spindles = model_prop("spindles", ModelCollection[Optional[Spindle]], ModelCollection(Optional[Spindle])) + state = model_prop("state", State) + tools = model_prop("tools", ModelCollection[Optional[Tool]], ModelCollection(Optional[Tool])) + volumes = model_prop("volumes", ModelCollection[Volume], ModelCollection(Volume)) def __init__(self): super(ObjectModel, self).__init__() - def _update_from_json(self, **kwargs) -> 'ObjectModel': + def _update_from_json(self, **kwargs: JSONElement) -> "ObjectModel": super(ObjectModel, self)._update_from_json(**kwargs) # "global" is a reserved keyword in Python, so it is converted to "globals" - if 'global_' in kwargs: - self.globals.update_from_json(kwargs.get('global_')) + if "global_" in kwargs: + global_ = kwargs["global_"] + if global_ is not None and not isinstance(global_, dict): + raise TypeError(f"global must be a dict or None. Got {type(global_).__name__}: {global_}") + self.globals.update_from_json(global_) return self diff --git a/src/dsf/object_model/plugins/__init__.py b/src/dsf/object_model/plugins/__init__.py index de6e18d..9bdba62 100644 --- a/src/dsf/object_model/plugins/__init__.py +++ b/src/dsf/object_model/plugins/__init__.py @@ -1,3 +1,5 @@ from .plugins import Plugin from .plugin_manifest import PluginManifest from .sbc_permissions import SbcPermissions + +__all__ = ["Plugin", "PluginManifest", "SbcPermissions"] diff --git a/src/dsf/object_model/plugins/plugin_manifest.py b/src/dsf/object_model/plugins/plugin_manifest.py index c3ad959..aee4a08 100644 --- a/src/dsf/object_model/plugins/plugin_manifest.py +++ b/src/dsf/object_model/plugins/plugin_manifest.py @@ -1,5 +1,4 @@ import re -from typing import List, Union from .sbc_permissions import SbcPermissions from ..model_object import ModelObject @@ -12,77 +11,77 @@ class PluginManifest(ModelObject): """Information about a third-party plugin""" # Identify of this plugin. May consist of letters and digits only (max length 32 chars) - id = model_prop('id', str) + id = model_prop("id", str) # Name of the plugin. May consist of letters, digits, dashes, and underscores only (max length 64 chars) - name = model_prop('name', str) + name = model_prop("name", str) # Author of the plugin - author = model_prop('author', str, "") + author = model_prop("author", str, "") # Version of the plugin - version = model_prop('version', str, "1.0.0") + version = model_prop("version", str, "1.0.0") # Licence of the plugin. Should follow the SPDX format (see https://spdx.org/licenses/) - license = model_prop('license', str, "LGPL-3.0-or-later") + license = model_prop("license", str, "LGPL-3.0-or-later") # Link to the plugin homepage or source code repository - homepage = nullable_model_prop('homepage', str) + homepage = nullable_model_prop("homepage", str) # List of general tags for search - tags = model_prop('tags', ModelCollection[str], ModelCollection(str)) + tags = model_prop("tags", ModelCollection[str], ModelCollection(str)) # Major/minor compatible DWC version - dwc_version = nullable_model_prop('dwc_version', str) + dwc_version = nullable_model_prop("dwc_version", str) # List of DWC plugins this plugin depends on. Circular dependencies are not supported - dwc_dependencies = model_prop('dwc_dependencies', ModelCollection[str], ModelCollection(str)) + dwc_dependencies = model_prop("dwc_dependencies", ModelCollection[str], ModelCollection(str)) # Set to true if a SBC is absolutely required for this plugin - sbc_required = model_prop('sbc_required', bool) + sbc_required = model_prop("sbc_required", bool) # Required DSF version for the plugin running on the SBC (ignored if there is no SBC executable) - sbc_dsf_version = nullable_model_prop('sbc_dsf_version', str) + sbc_dsf_version = nullable_model_prop("sbc_dsf_version", str) # Filename in the dsf directory used to start the plugin - sbc_executable = nullable_model_prop('sbc_executable', str) + sbc_executable = nullable_model_prop("sbc_executable", str) # Command-line arguments for the executable - sbc_executable_arguments = nullable_model_prop('sbc_executable_arguments', str) + sbc_executable_arguments = nullable_model_prop("sbc_executable_arguments", str) # List of other filenames in the dsf directory that should be executable - sbc_extra_executables = model_prop('sbc_extra_executables', ModelCollection[str], ModelCollection(str)) + sbc_extra_executables = model_prop("sbc_extra_executables", ModelCollection[str], ModelCollection(str)) # Automatically restart the SBC process when terminated - sbc_auto_restart = model_prop('sbc_auto_restart', bool, False) - + sbc_auto_restart = model_prop("sbc_auto_restart", bool, False) + # Plugin notifies DSF when it is fully started - sbc_notify_started = model_prop('sbc_notify_started', bool, False) + sbc_notify_started = model_prop("sbc_notify_started", bool, False) # Defines if messages from stdout/stderr are output as generic messages - sbc_output_redirected = model_prop('sbc_output_redirected', bool, False) + sbc_output_redirected = model_prop("sbc_output_redirected", bool, False) # List of permissins required by the plugin executable running on the SBC - sbc_permissions = model_prop('sbc_permissions', ModelCollection[SbcPermissions], ModelCollection(SbcPermissions)) + sbc_permissions = model_prop("sbc_permissions", ModelCollection[SbcPermissions], ModelCollection(SbcPermissions)) # List of files in the sys or virtual SD directory that should not be overwritten on upgrade - sbc_config_files = model_prop('sbc_config_files', ModelCollection[str], ModelCollection(str)) + sbc_config_files = model_prop("sbc_config_files", ModelCollection[str], ModelCollection(str)) # List of packages this plugin depends on (apt packages in the case of DuetPi) - sbc_package_dependencies = model_prop('sbc_package_dependencies', ModelCollection[str], ModelCollection(str)) + sbc_package_dependencies = model_prop("sbc_package_dependencies", ModelCollection[str], ModelCollection(str)) # List of Python packages this plugin depends on - sbc_python_dependencies = model_prop('sbc_python_dependencies', ModelCollection[str], ModelCollection(str)) + sbc_python_dependencies = model_prop("sbc_python_dependencies", ModelCollection[str], ModelCollection(str)) # List of SBC plugins this plugin depends on. Circular dependencies are not supported - sbc_plugin_dependencies = model_prop('sbc_plugin_dependencies', ModelCollection[str], ModelCollection(str)) + sbc_plugin_dependencies = model_prop("sbc_plugin_dependencies", ModelCollection[str], ModelCollection(str)) # Major/minor compatible RRF version - rrf_version = nullable_model_prop('rrf_version', str) + rrf_version = nullable_model_prop("rrf_version", str) # Custom plugin data to be populated in the object model (DSF/DWC in SBC mode - or - DWC in standalone mode). - data = model_prop('data', ModelDictionary, ModelDictionary(False)) - + data = model_prop("data", ModelDictionary, ModelDictionary(False)) + def __init__(self): super(PluginManifest, self).__init__() @@ -92,7 +91,7 @@ def check_version(actual: str, required: str): :param actual: Actual version :param required: Required version :returns: Whether the actual version fulfills teh requirement""" - split_chars = r'\.|-|\+' + split_chars = r"\.|-|\+" actual_items = re.split(split_chars, actual) required_items = re.split(split_chars, required) for actual_idx, required_idx in zip(actual_items, required_items): diff --git a/src/dsf/object_model/sbc/__init__.py b/src/dsf/object_model/sbc/__init__.py index 9173880..0ee716d 100644 --- a/src/dsf/object_model/sbc/__init__.py +++ b/src/dsf/object_model/sbc/__init__.py @@ -2,3 +2,5 @@ from .memory import Memory from .sbc import SBC from .upgrade import Upgrade + +__all__ = ["CPU", "Memory", "SBC", "Upgrade"] diff --git a/src/dsf/object_model/sbc/cpu.py b/src/dsf/object_model/sbc/cpu.py index 166db84..4a34a09 100644 --- a/src/dsf/object_model/sbc/cpu.py +++ b/src/dsf/object_model/sbc/cpu.py @@ -12,4 +12,3 @@ class CPU(ModelObject): def __init__(self): super().__init__() - diff --git a/src/dsf/object_model/sbc/dsf/__init__.py b/src/dsf/object_model/sbc/dsf/__init__.py index 69d777f..fd26813 100644 --- a/src/dsf/object_model/sbc/dsf/__init__.py +++ b/src/dsf/object_model/sbc/dsf/__init__.py @@ -1,3 +1,5 @@ from .dsf import DSF from .http_endpoint import HttpEndpoint, HttpEndpointType from .user_sessions import AccessLevel, SessionType, UserSession + +__all__ = ["DSF", "HttpEndpoint", "HttpEndpointType", "AccessLevel", "SessionType", "UserSession"] diff --git a/src/dsf/object_model/sbc/dsf/communication_method.py b/src/dsf/object_model/sbc/dsf/communication_method.py index a9d732a..d23fedd 100644 --- a/src/dsf/object_model/sbc/dsf/communication_method.py +++ b/src/dsf/object_model/sbc/dsf/communication_method.py @@ -8,4 +8,4 @@ class CommunicationMethod(str, Enum): SPI = "spi" # USB link adapter - USB = "usb" \ No newline at end of file + USB = "usb" diff --git a/src/dsf/object_model/sbc/dsf/dsf.py b/src/dsf/object_model/sbc/dsf/dsf.py index 6e47947..18fb918 100644 --- a/src/dsf/object_model/sbc/dsf/dsf.py +++ b/src/dsf/object_model/sbc/dsf/dsf.py @@ -9,17 +9,17 @@ class DSF(ModelObject): """Information about Duet Software Framework""" - build_date_time = model_prop('build_date_time', str, "") + build_date_time = model_prop("build_date_time", str, "") # Communication method used to talk to the firmware - communication_method = model_prop('communication_method', CommunicationMethod, CommunicationMethod.SPI) + communication_method = model_prop("communication_method", CommunicationMethod, CommunicationMethod.SPI) - http_endpoints = model_prop('http_endpoints', ModelCollection[HttpEndpoint], ModelCollection(HttpEndpoint)) - is64bit = model_prop('is64bit', bool, False) - plugin_support = model_prop('plugin_support', bool, False) - root_plugin_support = model_prop('root_plugin_support', bool, False) - user_sessions = model_prop('user_sessions', ModelCollection[UserSession], ModelCollection(UserSession)) - version = model_prop('version', str, "") + http_endpoints = model_prop("http_endpoints", ModelCollection[HttpEndpoint], ModelCollection(HttpEndpoint)) + is64bit = model_prop("is64bit", bool, False) + plugin_support = model_prop("plugin_support", bool, False) + root_plugin_support = model_prop("root_plugin_support", bool, False) + user_sessions = model_prop("user_sessions", ModelCollection[UserSession], ModelCollection(UserSession)) + version = model_prop("version", str, "") def __init__(self): super().__init__() diff --git a/src/dsf/object_model/sbc/dsf/http_endpoint.py b/src/dsf/object_model/sbc/dsf/http_endpoint.py index 893469e..cfabc10 100644 --- a/src/dsf/object_model/sbc/dsf/http_endpoint.py +++ b/src/dsf/object_model/sbc/dsf/http_endpoint.py @@ -25,4 +25,3 @@ class HttpEndpoint(ModelObject): def __init__(self): super().__init__() - diff --git a/src/dsf/object_model/sbc/dsf/user_sessions/__init__.py b/src/dsf/object_model/sbc/dsf/user_sessions/__init__.py index 5c92731..84a3a85 100644 --- a/src/dsf/object_model/sbc/dsf/user_sessions/__init__.py +++ b/src/dsf/object_model/sbc/dsf/user_sessions/__init__.py @@ -2,5 +2,4 @@ from .session_type import SessionType from .user_sessions import UserSession -__all__ = ['AccessLevel', 'SessionType', 'UserSession'] - +__all__ = ["AccessLevel", "SessionType", "UserSession"] diff --git a/src/dsf/object_model/sbc/sbc.py b/src/dsf/object_model/sbc/sbc.py index 5fbc6b3..fafc9f4 100644 --- a/src/dsf/object_model/sbc/sbc.py +++ b/src/dsf/object_model/sbc/sbc.py @@ -11,16 +11,16 @@ class SBC(ModelObject): """Information about the SBC in SBC mode""" - app_armor = model_prop('app_armor', bool, False) - cpu = model_prop('cpu', CPU) - dsf = model_prop('dsf', DSF) - distribution = nullable_model_prop('distribution', str) - distribution_build_time = nullable_model_prop('distribution_build_time', datetime, lambda: None) - memory = model_prop('memory', Memory) - model = nullable_model_prop('model', str) - serial = nullable_model_prop('serial', str) - upgrade = nullable_model_prop('upgrade', Upgrade) - uptime = nullable_model_prop('uptime', float) + app_armor = model_prop("app_armor", bool, False) + cpu = model_prop("cpu", CPU) + dsf = model_prop("dsf", DSF) + distribution = nullable_model_prop("distribution", str) + distribution_build_time = nullable_model_prop("distribution_build_time", datetime, lambda: None) + memory = model_prop("memory", Memory) + model = nullable_model_prop("model", str) + serial = nullable_model_prop("serial", str) + upgrade = nullable_model_prop("upgrade", Upgrade) + uptime = nullable_model_prop("uptime", float) def __init__(self): - super().__init__() \ No newline at end of file + super().__init__() diff --git a/src/dsf/object_model/sbc/upgrade.py b/src/dsf/object_model/sbc/upgrade.py index 6cacb3d..e922729 100644 --- a/src/dsf/object_model/sbc/upgrade.py +++ b/src/dsf/object_model/sbc/upgrade.py @@ -6,10 +6,10 @@ class Upgrade(ModelObject): """Details about a software upgrade in progress""" # Description of the current upgrade step - message = model_prop('message', str, "") + message = model_prop("message", str, "") # Progress of the current upgrade step (0..1) or None if indeterminate - progress = nullable_model_prop('progress', float) + progress = nullable_model_prop("progress", float) def __init__(self): super().__init__() diff --git a/src/dsf/object_model/sensors/__init__.py b/src/dsf/object_model/sensors/__init__.py index e1d7dae..12bcb9d 100644 --- a/src/dsf/object_model/sensors/__init__.py +++ b/src/dsf/object_model/sensors/__init__.py @@ -8,3 +8,16 @@ from .probe_load_cell import ProbeLoadCell from .probe_type import ProbeType from .sensors import Sensors + +__all__ = [ + "Accelerometer", + "AnalogSensor", + "AnalogSensorType", + "Endstop", + "EndstopType", + "GpInputPort", + "Probe", + "ProbeLoadCell", + "ProbeType", + "Sensors", +] diff --git a/src/dsf/object_model/sensors/accelerometer.py b/src/dsf/object_model/sensors/accelerometer.py index c0674c2..c6ad14a 100644 --- a/src/dsf/object_model/sensors/accelerometer.py +++ b/src/dsf/object_model/sensors/accelerometer.py @@ -9,13 +9,15 @@ class Accelerometer(ModelObject): """ # Orientation of the accelerometer - # See https://docs.duet3d.com/en/Duet3D_hardware/Accessories/Duet3D_Accelerometer#orientation for a list of orientations + # See the following page for a list of orientations: + # https://docs.duet3d.com/en/Duet3D_hardware/Accessories/Duet3D_Accelerometer#orientation orientation = model_prop("orientation", int, 20) # Number of collected data points in the last run or 0 if it failed points = model_prop("points", int) - # Port name(s) the accelerometer is connected to as passed to M955 C, including the CAN address prefix on expansion boards + # Port name(s) the accelerometer is connected to as passed to M955 C, + # including the CAN address prefix on expansion boards port = model_prop("port", str, "") # Resolution the accelerometer is programmed for (in bits) or 0 if unknown diff --git a/src/dsf/object_model/sensors/analog_sensor.py b/src/dsf/object_model/sensors/analog_sensor.py index 7de5dd1..e66e13f 100644 --- a/src/dsf/object_model/sensors/analog_sensor.py +++ b/src/dsf/object_model/sensors/analog_sensor.py @@ -1,5 +1,3 @@ -from typing import Union - from .analog_sensor_type import AnalogSensorType from .temperature_error import TemperatureError from ..model_object import ModelObject @@ -9,19 +7,19 @@ class AnalogSensor(ModelObject): """Representation of an analog sensor""" - beta = nullable_model_prop('beta', float) - c = nullable_model_prop('c', float) - high_reading = nullable_model_prop('high_reading', float) - last_reading = nullable_model_prop('last_reading', float) - low_reading = nullable_model_prop('low_reading', float) - name = nullable_model_prop('name', str) - offset_adj = model_prop('offset_adj', float, 0.0) - port = nullable_model_prop('port', str) - r_25 = nullable_model_prop('r_25', float) - r_ref = nullable_model_prop('r_ref', float) - slope_adj = model_prop('slope_adj', float, 0.0) - state = model_prop('state', TemperatureError, TemperatureError.ok) - type = model_prop('type', AnalogSensorType, AnalogSensorType.Unknown) + beta = nullable_model_prop("beta", float) + c = nullable_model_prop("c", float) + high_reading = nullable_model_prop("high_reading", float) + last_reading = nullable_model_prop("last_reading", float) + low_reading = nullable_model_prop("low_reading", float) + name = nullable_model_prop("name", str) + offset_adj = model_prop("offset_adj", float, 0.0) + port = nullable_model_prop("port", str) + r_25 = nullable_model_prop("r_25", float) + r_ref = nullable_model_prop("r_ref", float) + slope_adj = model_prop("slope_adj", float, 0.0) + state = model_prop("state", TemperatureError, TemperatureError.ok) + type = model_prop("type", AnalogSensorType, AnalogSensorType.Unknown) def __init__(self): super(AnalogSensor, self).__init__() diff --git a/src/dsf/object_model/sensors/analog_sensor_type.py b/src/dsf/object_model/sensors/analog_sensor_type.py index e2a0eb9..46c31ef 100644 --- a/src/dsf/object_model/sensors/analog_sensor_type.py +++ b/src/dsf/object_model/sensors/analog_sensor_type.py @@ -57,10 +57,10 @@ class AnalogSensorType(str, Enum): # ADS131 channel 0 (unipolar) ADS131Chan0Unipolar = "ads131.chan0.u" - + # ADS131 channel 0 (bipolar) ADS131Chan0Bipolar = "ads131.chan0.b" - + # ADS131 channel 1 ADS131Chan1 = "ads131.chan1" diff --git a/src/dsf/object_model/sensors/filament_monitors/__init__.py b/src/dsf/object_model/sensors/filament_monitors/__init__.py index 8bc5f78..5115c3a 100644 --- a/src/dsf/object_model/sensors/filament_monitors/__init__.py +++ b/src/dsf/object_model/sensors/filament_monitors/__init__.py @@ -3,14 +3,29 @@ from .filament_monitor_status import FilamentMonitorStatus from .filament_monitor_type import FilamentMonitorType from .laser_filament_monitor import LaserFilamentMonitor, LaserFilamentMonitorCalibrated, LaserFilamentMonitorConfigured -from .pulsed_filament_monitor import PulsedFilamentMonitor, PulsedFilamentMonitorCalibrated, \ - PulsedFilamentMonitorConfigured -from .rotating_magnet_filament_monitor import RotatingMagnetFilamentMonitor, RotatingMagnetFilamentMonitorCalibrated, \ - RotatingMagnetFilamentMonitorConfigured +from .pulsed_filament_monitor import ( + PulsedFilamentMonitor, + PulsedFilamentMonitorCalibrated, + PulsedFilamentMonitorConfigured, +) +from .rotating_magnet_filament_monitor import ( + RotatingMagnetFilamentMonitor, + RotatingMagnetFilamentMonitorCalibrated, + RotatingMagnetFilamentMonitorConfigured, +) - -__all_ = ['Duet3DFilamentMonitor', 'FilamentMonitor', 'FilamentMonitorStatus', 'FilamentMonitorType', - 'LaserFilamentMonitor', 'LaserFilamentMonitorCalibrated', 'LaserFilamentMonitorConfigured', - 'PulsedFilamentMonitor', 'PulsedFilamentMonitorCalibrated', 'PulsedFilamentMonitorConfigured', - 'RotatingMagnetFilamentMonitor', 'RotatingMagnetFilamentMonitorCalibrated', - 'RotatingMagnetFilamentMonitorConfigured'] +__all__ = [ + "Duet3DFilamentMonitor", + "FilamentMonitor", + "FilamentMonitorStatus", + "FilamentMonitorType", + "LaserFilamentMonitor", + "LaserFilamentMonitorCalibrated", + "LaserFilamentMonitorConfigured", + "PulsedFilamentMonitor", + "PulsedFilamentMonitorCalibrated", + "PulsedFilamentMonitorConfigured", + "RotatingMagnetFilamentMonitor", + "RotatingMagnetFilamentMonitorCalibrated", + "RotatingMagnetFilamentMonitorConfigured", +] diff --git a/src/dsf/object_model/sensors/filament_monitors/filament_monitor.py b/src/dsf/object_model/sensors/filament_monitors/filament_monitor.py index a20a381..80f3b79 100644 --- a/src/dsf/object_model/sensors/filament_monitors/filament_monitor.py +++ b/src/dsf/object_model/sensors/filament_monitors/filament_monitor.py @@ -3,6 +3,7 @@ from .filament_monitor_type import FilamentMonitorType from ...model_object import ModelObject from ...utils import model_prop, nullable_model_prop +from ....utils import JSONElement class FilamentMonitor(ModelObject): @@ -29,15 +30,12 @@ def __init__(self, type_: FilamentMonitorType = FilamentMonitorType.Unknown): self.type = type_ @staticmethod - def get_filament_monitor(type_: FilamentMonitorType): + def get_filament_monitor(type_: FilamentMonitorType | str) -> "FilamentMonitor": from .laser_filament_monitor import LaserFilamentMonitor from .pulsed_filament_monitor import PulsedFilamentMonitor from .rotating_magnet_filament_monitor import RotatingMagnetFilamentMonitor - if isinstance(type_, str): - type_ = FilamentMonitorType(type_) - elif not isinstance(type_, FilamentMonitorType): - raise TypeError(f"type must be of type FilamentMonitorType. Got {type(type_)}: {type_}") + type_ = FilamentMonitorType(type_) if type_ == FilamentMonitorType.Laser: return LaserFilamentMonitor() @@ -48,12 +46,11 @@ def get_filament_monitor(type_: FilamentMonitorType): else: return FilamentMonitor(type_) - def _update_from_json(self, **kwargs): + def _update_from_json(self, **kwargs: JSONElement) -> "FilamentMonitor": """Override ObjectModel._update_from_json to return the FilamentMonitorType type matching the given type""" - if 'type_' in kwargs and self.type != FilamentMonitorType(kwargs.get('type_')): - required_type = self.get_filament_monitor(kwargs.get('type_')) - new_filament_monitor = required_type.update_from_json(kwargs) - return new_filament_monitor + type_ = kwargs.get("type_") + if isinstance(type_, str) and self.type != FilamentMonitorType(type_): + return self.get_filament_monitor(type_).update_from_json(kwargs) super(FilamentMonitor, self)._update_from_json(**kwargs) return self diff --git a/src/dsf/object_model/sensors/filament_monitors/laser_filament_monitor.py b/src/dsf/object_model/sensors/filament_monitors/laser_filament_monitor.py index c1fa5e8..0c9ea51 100644 --- a/src/dsf/object_model/sensors/filament_monitors/laser_filament_monitor.py +++ b/src/dsf/object_model/sensors/filament_monitors/laser_filament_monitor.py @@ -49,10 +49,10 @@ class LaserFilamentMonitor(Duet3DFilamentMonitor): """Information about a laser filament monitor""" # Calibrated properties of this filament monitor - calibrated = nullable_model_prop('calibrated', LaserFilamentMonitorCalibrated) + calibrated = nullable_model_prop("calibrated", LaserFilamentMonitorCalibrated) # Configured properties of this filament monitor - configured = model_prop('configured', LaserFilamentMonitorConfigured, LaserFilamentMonitorConfigured()) + configured = model_prop("configured", LaserFilamentMonitorConfigured, LaserFilamentMonitorConfigured()) def __init__(self): super(LaserFilamentMonitor, self).__init__(FilamentMonitorType.Laser) diff --git a/src/dsf/object_model/sensors/filament_monitors/pulsed_filament_monitor.py b/src/dsf/object_model/sensors/filament_monitors/pulsed_filament_monitor.py index cde7426..8c23342 100644 --- a/src/dsf/object_model/sensors/filament_monitors/pulsed_filament_monitor.py +++ b/src/dsf/object_model/sensors/filament_monitors/pulsed_filament_monitor.py @@ -46,13 +46,13 @@ class PulsedFilamentMonitor(FilamentMonitor): """Information about a pulsed filament monitor""" # Calibrated properties of this filament monitor - calibrated = nullable_model_prop('calibrated', PulsedFilamentMonitorCalibrated) + calibrated = nullable_model_prop("calibrated", PulsedFilamentMonitorCalibrated) # Configured properties of this filament monitor - configured = model_prop('configured', PulsedFilamentMonitorConfigured, PulsedFilamentMonitorConfigured()) + configured = model_prop("configured", PulsedFilamentMonitorConfigured, PulsedFilamentMonitorConfigured()) # Position of the sensor (in mm) - position = model_prop('position', float) + position = model_prop("position", float) def __init__(self): super(PulsedFilamentMonitor, self).__init__(FilamentMonitorType.Pulsed) diff --git a/src/dsf/object_model/sensors/filament_monitors/rotating_magnet_filament_monitor.py b/src/dsf/object_model/sensors/filament_monitors/rotating_magnet_filament_monitor.py index eb0cf1e..604437e 100644 --- a/src/dsf/object_model/sensors/filament_monitors/rotating_magnet_filament_monitor.py +++ b/src/dsf/object_model/sensors/filament_monitors/rotating_magnet_filament_monitor.py @@ -49,13 +49,13 @@ class RotatingMagnetFilamentMonitor(Duet3DFilamentMonitor): """Information about a rotating magnet filament monitor""" # AGC reading of this filament monitor (None if unknown) - agc = nullable_model_prop('agc', int) + agc = nullable_model_prop("agc", int) # Calibrated properties of this filament monitor - calibrated = nullable_model_prop('calibrated', RotatingMagnetFilamentMonitorCalibrated) + calibrated = nullable_model_prop("calibrated", RotatingMagnetFilamentMonitorCalibrated) # Configured properties of this filament monitor - configured = model_prop('configured', RotatingMagnetFilamentMonitorConfigured) + configured = model_prop("configured", RotatingMagnetFilamentMonitorConfigured) def __init__(self): super(RotatingMagnetFilamentMonitor, self).__init__(FilamentMonitorType.RotatingMagnet) diff --git a/src/dsf/object_model/sensors/probe.py b/src/dsf/object_model/sensors/probe.py index ad6b75b..bad3345 100644 --- a/src/dsf/object_model/sensors/probe.py +++ b/src/dsf/object_model/sensors/probe.py @@ -9,29 +9,31 @@ class Probe(ModelObject): """Information about a configured probe""" - calibration_temperature = model_prop('calibration_temperature', float, 0) - deployed_by_user = model_prop('deployed_by_user', bool, False) - disables_heaters = model_prop('disables_heaters', bool, False) + calibration_temperature = model_prop("calibration_temperature", float, 0) + deployed_by_user = model_prop("deployed_by_user", bool, False) + disables_heaters = model_prop("disables_heaters", bool, False) # Obsolete: use dive_heights instead - dive_height = model_prop('dive_height', float, 0) - dive_heights = model_prop('dive_heights', ModelCollection[float], ModelCollection(float, [0.0, 0.0])) - is_calibrated = nullable_model_prop('is_calibrated', bool) - last_stop_height = model_prop('last_stop_height', float, 0) - load_cell = nullable_model_prop('load_cell', ProbeLoadCell) - max_probe_count = model_prop('max_probe_count', int, 1) - measured_height = nullable_model_prop('measured_height', float) - offsets = model_prop('offsets', ModelCollection[float], ModelCollection(float, [0.0, 0.0])) - recovery_time = model_prop('recovery_time', float, 0) - scan_coefficients = nullable_model_prop('scan_coefficients', ModelCollection[float], lambda: ModelCollection(float)) - speeds = model_prop('speeds', ModelCollection[float], ModelCollection(float, [2.0, 2.0])) - temperature_coefficients = model_prop('temperature_coefficients', ModelCollection[float], ModelCollection(float, [0.0, 0.0])) - threshold = model_prop('threshold', int, 500) - tolerance = model_prop('tolerance', float, 0.03) - touch_mode = nullable_model_prop('touch_mode', ProbeTouchMode) - travel_speed = model_prop('travel_speed', float, 6000) - trigger_height = model_prop('trigger_height', float, 0.7) - type = model_prop('type', ProbeType, ProbeType.NoProbe) - value = model_prop('value', ModelCollection[int], ModelCollection(int)) + dive_height = model_prop("dive_height", float, 0) + dive_heights = model_prop("dive_heights", ModelCollection[float], ModelCollection(float, [0.0, 0.0])) + is_calibrated = nullable_model_prop("is_calibrated", bool) + last_stop_height = model_prop("last_stop_height", float, 0) + load_cell = nullable_model_prop("load_cell", ProbeLoadCell) + max_probe_count = model_prop("max_probe_count", int, 1) + measured_height = nullable_model_prop("measured_height", float) + offsets = model_prop("offsets", ModelCollection[float], ModelCollection(float, [0.0, 0.0])) + recovery_time = model_prop("recovery_time", float, 0) + scan_coefficients = nullable_model_prop("scan_coefficients", ModelCollection[float], lambda: ModelCollection(float)) + speeds = model_prop("speeds", ModelCollection[float], ModelCollection(float, [2.0, 2.0])) + temperature_coefficients = model_prop( + "temperature_coefficients", ModelCollection[float], ModelCollection(float, [0.0, 0.0]) + ) + threshold = model_prop("threshold", int, 500) + tolerance = model_prop("tolerance", float, 0.03) + touch_mode = nullable_model_prop("touch_mode", ProbeTouchMode) + travel_speed = model_prop("travel_speed", float, 6000) + trigger_height = model_prop("trigger_height", float, 0.7) + type = model_prop("type", ProbeType, ProbeType.NoProbe) + value = model_prop("value", ModelCollection[int], ModelCollection(int)) def __init__(self): super(Probe, self).__init__() diff --git a/src/dsf/object_model/sensors/probe_touch_mode.py b/src/dsf/object_model/sensors/probe_touch_mode.py index f44e1bf..277421c 100644 --- a/src/dsf/object_model/sensors/probe_touch_mode.py +++ b/src/dsf/object_model/sensors/probe_touch_mode.py @@ -11,4 +11,4 @@ class ProbeTouchMode(ModelObject): trigger_height = model_prop("trigger_height", float) def __init__(self): - super(ProbeTouchMode, self).__init__() \ No newline at end of file + super(ProbeTouchMode, self).__init__() diff --git a/src/dsf/object_model/sensors/probe_type.py b/src/dsf/object_model/sensors/probe_type.py index 66fb589..bebb274 100644 --- a/src/dsf/object_model/sensors/probe_type.py +++ b/src/dsf/object_model/sensors/probe_type.py @@ -5,34 +5,34 @@ class ProbeType(IntEnum): """Supported probe types""" # No probe - NoProbe = 0, + NoProbe = 0 # A simple unmodulated probe (like dc42's infrared probe) - Analog = 1, + Analog = 1 # A modulated probe (like the original one shipped with the RepRapPro Ormerod) - DumbModulated = 2, + DumbModulated = 2 # Alternate analog probe (like the ultrasonic probe) - AlternateAnalog = 3, + AlternateAnalog = 3 # Endstop switch (obsolete, should not be used anymore) - EndstopSwitch_Obsolete = 4, + EndstopSwitch_Obsolete = 4 # A switch that is triggered when the probe is activated (filtered) - Digital = 5, + Digital = 5 # Endstop switch on the E1 endstop pin (obsolete, should not be used anymore) - E1Switch_Obsolete = 6, + E1Switch_Obsolete = 6 # Endstop switch on Z endstop pin (obsolete, should not be used anymore) - ZSwitch_Obsolete = 7, + ZSwitch_Obsolete = 7 # A switch that is triggered when the probe is activated (unfiltered) - UnfilteredDigital = 8, + UnfilteredDigital = 8 # A BLTouch probe - BLTouch = 9, + BLTouch = 9 # Z motor stall detection ZMotorStall = 10 diff --git a/src/dsf/object_model/sensors/sensors.py b/src/dsf/object_model/sensors/sensors.py index b6d5e23..0205989 100644 --- a/src/dsf/object_model/sensors/sensors.py +++ b/src/dsf/object_model/sensors/sensors.py @@ -15,7 +15,9 @@ class Sensors(ModelObject): """Information about sensors""" # List of configured accelerometers, indexed by their M955/M956 P number - accelerometers = model_prop("accelerometers", ModelCollection[Optional[Accelerometer]], ModelCollection(Optional[Accelerometer])) + accelerometers = model_prop( + "accelerometers", ModelCollection[Optional[Accelerometer]], ModelCollection(Optional[Accelerometer]) + ) # List of analog sensors analog = model_prop("analog", ModelCollection[Optional[AnalogSensor]], ModelCollection(Optional[AnalogSensor])) @@ -24,7 +26,9 @@ class Sensors(ModelObject): endstops = model_prop("endstops", ModelCollection[Optional[Endstop]], ModelCollection(Optional[Endstop])) # List of configured filament monitors - filament_monitors = model_prop("filament_monitors", ModelCollection[Optional[FilamentMonitor]], ModelCollection(Optional[FilamentMonitor])) + filament_monitors = model_prop( + "filament_monitors", ModelCollection[Optional[FilamentMonitor]], ModelCollection(Optional[FilamentMonitor]) + ) # List of general-purpose input ports gp_in = model_prop("gp_in", ModelCollection[Optional[GpInputPort]], ModelCollection(Optional[GpInputPort])) diff --git a/src/dsf/object_model/spindles/__init__.py b/src/dsf/object_model/spindles/__init__.py index c7b00a6..5f6b68e 100644 --- a/src/dsf/object_model/spindles/__init__.py +++ b/src/dsf/object_model/spindles/__init__.py @@ -1,4 +1,4 @@ from .spindles import Spindle from .spindle_state import SpindleState -__all__ = ['Spindle', 'SpindleState'] +__all__ = ["Spindle", "SpindleState"] diff --git a/src/dsf/object_model/state/__init__.py b/src/dsf/object_model/state/__init__.py index 1c4281a..8542d5e 100644 --- a/src/dsf/object_model/state/__init__.py +++ b/src/dsf/object_model/state/__init__.py @@ -6,3 +6,15 @@ from .restore_point import RestorePoint from .startup_error import StartupError from .state import State + +__all__ = [ + "BeepRequest", + "GpOutputPort", + "LogLevel", + "MachineStatus", + "MessageBox", + "MessageBoxMode", + "RestorePoint", + "StartupError", + "State", +] diff --git a/src/dsf/object_model/state/message_box.py b/src/dsf/object_model/state/message_box.py index db11d74..740bd80 100644 --- a/src/dsf/object_model/state/message_box.py +++ b/src/dsf/object_model/state/message_box.py @@ -1,5 +1,4 @@ from enum import IntEnum -from typing import List, Union from ..model_object import ModelObject from ..model_collection import ModelCollection from ..utils import model_prop, nullable_model_prop @@ -36,17 +35,17 @@ class MessageBoxMode(IntEnum): class MessageBox(ModelObject): """Information about the message box to show""" - axis_controls = nullable_model_prop('axis_controls', int) - cancel_button = model_prop('cancel_button', bool, False) - choices = nullable_model_prop('choices', ModelCollection[str], lambda: ModelCollection(str)) - default = nullable_model_prop('default', object) - max = nullable_model_prop('max', float) - message = model_prop('message', str, "") - min = nullable_model_prop('min', float) - mode = model_prop('mode', MessageBoxMode, MessageBoxMode.OkOnly) - seq = model_prop('seq', int, -1) - timeout = model_prop('timeout', int, 0) - title = model_prop('title', str, "") + axis_controls = nullable_model_prop("axis_controls", int) + cancel_button = model_prop("cancel_button", bool, False) + choices = nullable_model_prop("choices", ModelCollection[str], lambda: ModelCollection(str)) + default = nullable_model_prop("default", object) + max = nullable_model_prop("max", float) + message = model_prop("message", str, "") + min = nullable_model_prop("min", float) + mode = model_prop("mode", MessageBoxMode, MessageBoxMode.OkOnly) + seq = model_prop("seq", int, -1) + timeout = model_prop("timeout", int, 0) + title = model_prop("title", str, "") def __init__(self): super(MessageBox, self).__init__() diff --git a/src/dsf/object_model/state/restore_point.py b/src/dsf/object_model/state/restore_point.py index cdb4637..7f4004d 100644 --- a/src/dsf/object_model/state/restore_point.py +++ b/src/dsf/object_model/state/restore_point.py @@ -1,6 +1,3 @@ -from typing import List, Union - - from ..model_object import ModelObject from ..model_collection import ModelCollection from ..utils import model_prop, nullable_model_prop @@ -11,14 +8,14 @@ class RestorePoint(ModelObject): Class holding information about a restore point """ - coords = model_prop('coords', ModelCollection[float], ModelCollection(float)) - extruder_pos = model_prop('extruder_pos', float, 0) - fan_pwm = model_prop('fan_pwm', float, 0) - feed_rate = model_prop('feed_rate', float, 0) - g_command_number = model_prop('g_command_number', int, -1) - io_bits = nullable_model_prop('io_bits', int) - laser_pwm = nullable_model_prop('laser_pwm', float) - tool_number = model_prop('tool_number', int, -1) + coords = model_prop("coords", ModelCollection[float], ModelCollection(float)) + extruder_pos = model_prop("extruder_pos", float, 0) + fan_pwm = model_prop("fan_pwm", float, 0) + feed_rate = model_prop("feed_rate", float, 0) + g_command_number = model_prop("g_command_number", int, -1) + io_bits = nullable_model_prop("io_bits", int) + laser_pwm = nullable_model_prop("laser_pwm", float) + tool_number = model_prop("tool_number", int, -1) def __init__(self): super(RestorePoint, self).__init__() diff --git a/src/dsf/object_model/state/state.py b/src/dsf/object_model/state/state.py index d5e74a7..9dd1b3e 100644 --- a/src/dsf/object_model/state/state.py +++ b/src/dsf/object_model/state/state.py @@ -17,31 +17,31 @@ class State(ModelObject): """Information about the machine state""" - atx_power = nullable_model_prop('atx_power', bool) - atx_power_port = nullable_model_prop('atx_power_port', str) - beep = nullable_model_prop('beep', BeepRequest) - current_tool = model_prop('current_tool', int, -1) - deferred_power_down = nullable_model_prop('deferred_power_down', bool) - display_message = model_prop('display_message', str, "") - gp_out = model_prop('gp_out', ModelCollection[Optional[GpOutputPort]], ModelCollection(Optional[GpOutputPort])) - laser_pwm = nullable_model_prop('laser_pwm', float) - log_file = nullable_model_prop('log_file', str) - log_level = model_prop('log_level', LogLevel, LogLevel.Off) - message_box = nullable_model_prop('message_box', MessageBox) - machine_mode = model_prop('machine_mode', MachineMode, MachineMode.FFF) - macro_restarted = model_prop('macro_restarted', bool, False) - ms_up_time = model_prop('ms_up_time', int) - next_tool = model_prop('next_tool', int, -1) - plugins_started = model_prop('plugins_started', bool, False) - power_fail_script = model_prop('power_fail_script', str, "") - previous_tool = model_prop('previous_tool', int, -1) - restore_points = model_prop('restore_points', ModelCollection[RestorePoint], ModelCollection(RestorePoint)) - startup_error = nullable_model_prop('startup_error', StartupError) - status = model_prop('status', MachineStatus, MachineStatus.starting) - this_active = nullable_model_prop('this_active', bool) - this_input = nullable_model_prop('this_input', int) - time = nullable_model_prop('time', datetime, lambda: None) - up_time = model_prop('up_time', int, 0) + atx_power = nullable_model_prop("atx_power", bool) + atx_power_port = nullable_model_prop("atx_power_port", str) + beep = nullable_model_prop("beep", BeepRequest) + current_tool = model_prop("current_tool", int, -1) + deferred_power_down = nullable_model_prop("deferred_power_down", bool) + display_message = model_prop("display_message", str, "") + gp_out = model_prop("gp_out", ModelCollection[Optional[GpOutputPort]], ModelCollection(Optional[GpOutputPort])) + laser_pwm = nullable_model_prop("laser_pwm", float) + log_file = nullable_model_prop("log_file", str) + log_level = model_prop("log_level", LogLevel, LogLevel.Off) + message_box = nullable_model_prop("message_box", MessageBox) + machine_mode = model_prop("machine_mode", MachineMode, MachineMode.FFF) + macro_restarted = model_prop("macro_restarted", bool, False) + ms_up_time = model_prop("ms_up_time", int) + next_tool = model_prop("next_tool", int, -1) + plugins_started = model_prop("plugins_started", bool, False) + power_fail_script = model_prop("power_fail_script", str, "") + previous_tool = model_prop("previous_tool", int, -1) + restore_points = model_prop("restore_points", ModelCollection[RestorePoint], ModelCollection(RestorePoint)) + startup_error = nullable_model_prop("startup_error", StartupError) + status = model_prop("status", MachineStatus, MachineStatus.starting) + this_active = nullable_model_prop("this_active", bool) + this_input = nullable_model_prop("this_input", int) + time = nullable_model_prop("time", datetime, lambda: None) + up_time = model_prop("up_time", int, 0) def __init__(self): super(State, self).__init__() diff --git a/src/dsf/object_model/tools/__init__.py b/src/dsf/object_model/tools/__init__.py index 9ea29dc..44b9742 100644 --- a/src/dsf/object_model/tools/__init__.py +++ b/src/dsf/object_model/tools/__init__.py @@ -2,4 +2,4 @@ from .tool_state import ToolState from .tool_retraction import ToolRetraction -__all__ = ['Tool', 'ToolState', 'ToolRetraction'] +__all__ = ["Tool", "ToolState", "ToolRetraction"] diff --git a/src/dsf/object_model/tools/tool_retraction.py b/src/dsf/object_model/tools/tool_retraction.py index f12eaee..7bcab9e 100644 --- a/src/dsf/object_model/tools/tool_retraction.py +++ b/src/dsf/object_model/tools/tool_retraction.py @@ -21,4 +21,4 @@ class ToolRetraction(ModelObject): z_hop = model_prop("z_hop", float, 0) def __init__(self): - super().__init__() + super().__init__() diff --git a/src/dsf/object_model/tools/tools.py b/src/dsf/object_model/tools/tools.py index 0319acc..620d4ff 100644 --- a/src/dsf/object_model/tools/tools.py +++ b/src/dsf/object_model/tools/tools.py @@ -1,38 +1,35 @@ -from typing import List - from .tool_state import ToolState from .tool_retraction import ToolRetraction from ..model_object import ModelObject from ..model_collection import ModelCollection from ..utils import model_prop, nullable_model_prop -from ...utils import deprecated class Tool(ModelObject): """Information about a configured tool""" - active = model_prop('active', ModelCollection[float], ModelCollection(float)) - axes = model_prop('axes', ModelCollection[list[int]], ModelCollection(list[int])) - extruders = model_prop('extruders', ModelCollection[int], ModelCollection(int)) - fans = model_prop('fans', ModelCollection[int], ModelCollection(int)) + active = model_prop("active", ModelCollection[float], ModelCollection(float)) + axes = model_prop("axes", ModelCollection[list[int]], ModelCollection(list[int])) + extruders = model_prop("extruders", ModelCollection[int], ModelCollection(int)) + fans = model_prop("fans", ModelCollection[int], ModelCollection(int)) # Obsolete: use feed_forward_pwm instead - feed_forward = model_prop('feed_forward', ModelCollection[float], ModelCollection(float)) - feed_forward_advance = nullable_model_prop('feed_forward_advance', float) - feed_forward_pwm = model_prop('feed_forward_pwm', ModelCollection[float], ModelCollection(float)) - feed_forward_temp = model_prop('feed_forward_temp', ModelCollection[float], ModelCollection(float)) - filament_extruder = model_prop('filament_extruder', int, -1) - heaters = model_prop('heaters', ModelCollection[int], ModelCollection(int)) - is_retracted = model_prop('is_retracted', bool, False) - mix = model_prop('mix', ModelCollection[float], ModelCollection(float)) - name = model_prop('name', str, "") - number = model_prop('number', int, 0) - offsets = model_prop('offsets', ModelCollection[float], ModelCollection(float)) - offsets_probed = model_prop('offsets_probed', int, 0) - retraction = model_prop('retraction', ToolRetraction) - spindle = model_prop('spindle', int, -1) - spindle_rpm = model_prop('spindle_rpm', int, 0) - standby = model_prop('standby', ModelCollection[float], ModelCollection(float)) - state = model_prop('state', ToolState, ToolState.off) + feed_forward = model_prop("feed_forward", ModelCollection[float], ModelCollection(float)) + feed_forward_advance = nullable_model_prop("feed_forward_advance", float) + feed_forward_pwm = model_prop("feed_forward_pwm", ModelCollection[float], ModelCollection(float)) + feed_forward_temp = model_prop("feed_forward_temp", ModelCollection[float], ModelCollection(float)) + filament_extruder = model_prop("filament_extruder", int, -1) + heaters = model_prop("heaters", ModelCollection[int], ModelCollection(int)) + is_retracted = model_prop("is_retracted", bool, False) + mix = model_prop("mix", ModelCollection[float], ModelCollection(float)) + name = model_prop("name", str, "") + number = model_prop("number", int, 0) + offsets = model_prop("offsets", ModelCollection[float], ModelCollection(float)) + offsets_probed = model_prop("offsets_probed", int, 0) + retraction = model_prop("retraction", ToolRetraction) + spindle = model_prop("spindle", int, -1) + spindle_rpm = model_prop("spindle_rpm", int, 0) + standby = model_prop("standby", ModelCollection[float], ModelCollection(float)) + state = model_prop("state", ToolState, ToolState.off) def __init__(self): super().__init__() diff --git a/src/dsf/object_model/utils.py b/src/dsf/object_model/utils.py index d23b4ed..a16769e 100644 --- a/src/dsf/object_model/utils.py +++ b/src/dsf/object_model/utils.py @@ -1,30 +1,28 @@ import copy from datetime import datetime -from typing import Optional, Protocol, TypeVar, Union, Callable, cast, get_origin, overload +from typing import Any, Optional, Protocol, TypeVar, TypeGuard, Union, Callable, cast, get_origin, overload from .model_object import ModelObject from .model_collection import ModelCollection from .model_dictionary import ModelDictionary +from .model_type import ModelType from ..utils import JSONObj, JSONElement - _TProperty = TypeVar("_TProperty", covariant=True) class TypedReadableProperty(Protocol[_TProperty]): @overload - def __get__(self, obj: None, objtype: Optional[type[object]] = None) -> "TypedReadableProperty[_TProperty]": - ... + def __get__(self, obj: None, objtype: Optional[type[object]] = None) -> "TypedReadableProperty[_TProperty]": ... @overload - def __get__(self, obj: object, objtype: Optional[type[object]] = None) -> _TProperty: - ... + def __get__(self, obj: object, objtype: Optional[type[object]] = None) -> _TProperty: ... T = TypeVar("T") -def is_model_object(o: object) -> bool: +def is_model_object(o: object) -> TypeGuard[ModelType[Any]]: from .model_object import ModelObject from .model_collection import ModelCollection from .model_dictionary import ModelDictionary @@ -32,42 +30,57 @@ def is_model_object(o: object) -> bool: return isinstance(o, ModelObject) or isinstance(o, ModelCollection) or isinstance(o, ModelDictionary) -def _set_model_prop(instance: object, name: str, runtime_type: type[JSONElement | datetime], current_value: T, value: Union[T, JSONElement]): +def _set_model_prop( + instance: object, name: str, runtime_type: type[object], current_value: T, value: Union[T, JSONElement] +) -> None: if value is None and isinstance(current_value, ModelDictionary): # DSF sends null to clear a dictionary - current_value.update_from_json(cast(JSONObj, None)) + current_value.update_from_json(None) elif isinstance(value, dict): # Update from JSON if not isinstance(current_value, (ModelObject, ModelDictionary)): - raise TypeError(f"{instance.__class__.__name__}.{name} must be of type ModelObject or ModelDictionary to update from a dict." - f" Got {type(current_value).__name__}: {current_value}") + raise TypeError( + f"{instance.__class__.__name__}.{name} must be of type ModelObject or ModelDictionary" + f" to update from a dict." + f" Got {type(current_value).__name__}: {current_value}" + ) current_value.update_from_json(cast(JSONObj, value)) elif isinstance(value, list): if not isinstance(current_value, ModelCollection): - raise TypeError(f"{instance.__class__.__name__}.{name} must be of type ModelCollection to update from a list." - f" Got {type(current_value).__name__}: {current_value}") + raise TypeError( + f"{instance.__class__.__name__}.{name} must be of type ModelCollection to update from a list." + f" Got {type(current_value).__name__}: {current_value}" + ) current_value.update_from_json(cast(list[JSONElement], value)) elif runtime_type is datetime and isinstance(value, str): try: parsed_date = datetime.fromisoformat(value) setattr(instance, name, parsed_date) except ValueError: - raise TypeError(f"{instance.__class__.__name__}.{name} must be a valid ISO format datetime string to update from JSON. Got: {value}") + raise TypeError( + f"{instance.__class__.__name__}.{name} must be a valid ISO format datetime string to update from JSON." + f" Got: {value}" + ) elif isinstance(value, (str, int, float, bool)): - value = runtime_type(value) # ignore type - setattr(instance, name, value) + converter = cast(Callable[[object], object], runtime_type) + setattr(instance, name, converter(value)) else: - raise TypeError(f"{instance.__class__.__name__}.{name} must be of type {runtime_type} or a compatible JSON element to update from." - f" Got {type(value).__name__}: {value}") + raise TypeError( + f"{instance.__class__.__name__}.{name} must be of type {runtime_type}" + f" or a compatible JSON element to update from." + f" Got {type(value).__name__}: {value}" + ) + def model_prop(name: str, model_type: type[T], default: Optional[T] = None) -> TypedReadableProperty[T]: """ Wrap a none nullable model object property so that type checks can be performed during update :param name: Property of the derived class :param model_type: Constructor for creating new elements - :param default: Default value of type to use if not set during update from JSON, if None then the default constructor of the model is used + :param default: Default value of type to use if not set during update from JSON, + if None then the default constructor of the model is used :return: """ - STORAGE_NAME = '_' + name + STORAGE_NAME = "_" + name runtime_model_type = cast(type[object], get_origin(model_type) or model_type) # Mutable model defaults must never be shared between instances. @@ -81,19 +94,17 @@ def _make_default() -> T: if _mutable_model_default: if default is None: return model_type() - return cast(T, copy.deepcopy(default)) + return copy.deepcopy(default) return cast(T, _scalar_default) - @property - def prop(self: object) -> T: + def getter(self: object) -> T: v = getattr(self, STORAGE_NAME, None) if v is None: v = _make_default() setattr(self, STORAGE_NAME, v) return v - @prop.setter - def prop(self: object, value: Union[T, JSONElement]) -> None: + def setter(self: object, value: Union[T, JSONElement]) -> None: def get_or_create_value() -> T: current_value: Optional[T] = getattr(self, STORAGE_NAME, None) if current_value is None: @@ -104,13 +115,16 @@ def get_or_create_value() -> T: if isinstance(value, runtime_model_type): setattr(self, STORAGE_NAME, value) return - + current_value = get_or_create_value() _set_model_prop(self, STORAGE_NAME, runtime_model_type, current_value, value) - return cast(TypedReadableProperty[T], prop) + return cast(TypedReadableProperty[T], property(getter, setter)) + -def nullable_model_prop(name: str, model_type: type[T], constructor: Optional[Callable[[], T]] = None) -> TypedReadableProperty[Optional[T]]: +def nullable_model_prop( + name: str, model_type: type[T], constructor: Optional[Callable[[], Optional[T]]] = None +) -> TypedReadableProperty[Optional[T]]: """ Wrap a nullable model object property so that type checks can be performed during update :param name: Property of the derived class @@ -119,7 +133,7 @@ def nullable_model_prop(name: str, model_type: type[T], constructor: Optional[Ca :return: TypedReadableProperty[Optional[T]] """ - STORAGE_NAME = '_' + name + STORAGE_NAME = "_" + name runtime_model_type = cast(type[object], get_origin(model_type) or model_type) model_type_name = getattr(runtime_model_type, "__name__", str(model_type)) @@ -127,17 +141,18 @@ def nullable_model_prop(name: str, model_type: type[T], constructor: Optional[Ca try: model_type() # validate that the default constructor works except Exception: - raise TypeError(f"Default constructor failed for type {model_type_name}. Provide a constructor function to create default values for the property {name}.") + raise TypeError( + f"Default constructor failed for type {model_type_name}." + f" Provide a constructor function to create default values for the property {name}." + ) _factory: Callable[[], Optional[T]] = model_type else: _factory = constructor - @property - def prop(self: object) -> Optional[T]: + def getter(self: object) -> Optional[T]: return getattr(self, STORAGE_NAME, None) - @prop.setter - def prop(self: object, value: Union[T, JSONElement, None]) -> None: + def setter(self: object, value: Union[T, JSONElement, None]) -> None: def get_or_create_value() -> Optional[T]: v = getattr(self, STORAGE_NAME, None) if v is None: @@ -153,4 +168,4 @@ def get_or_create_value() -> Optional[T]: current_value = get_or_create_value() _set_model_prop(self, STORAGE_NAME, runtime_model_type, current_value, value) - return cast(TypedReadableProperty[Optional[T]], prop) + return cast(TypedReadableProperty[Optional[T]], property(getter, setter)) diff --git a/src/dsf/object_model/volumes/__init__.py b/src/dsf/object_model/volumes/__init__.py index 42acc50..a7627d7 100644 --- a/src/dsf/object_model/volumes/__init__.py +++ b/src/dsf/object_model/volumes/__init__.py @@ -1,3 +1,3 @@ from .volumes import Volume -__all__ = ['Volume'] +__all__ = ["Volume"] diff --git a/src/dsf/object_model/volumes/volumes.py b/src/dsf/object_model/volumes/volumes.py index 0cb60e2..f9eee1a 100644 --- a/src/dsf/object_model/volumes/volumes.py +++ b/src/dsf/object_model/volumes/volumes.py @@ -6,28 +6,28 @@ class Volume(ModelObject): """Information about a storage device""" # Total capacity of the storage device (in bytes or null) - capacity = nullable_model_prop('capacity', int) - + capacity = nullable_model_prop("capacity", int) + # How much space is still available on this device (in bytes or null) - free_space = nullable_model_prop('free_space', int) - + free_space = nullable_model_prop("free_space", int) + # Whether the storage device is mounted - mounted = model_prop('mounted', bool) - + mounted = model_prop("mounted", bool) + # Name of this volume - name = nullable_model_prop('name', str) - + name = nullable_model_prop("name", str) + # Whether any file is open on this volume or null if unknown - open_files = nullable_model_prop('open_files', bool) - + open_files = nullable_model_prop("open_files", bool) + # Total size of this volume (in bytes or null) - partition_size = nullable_model_prop('partition_size', int) - + partition_size = nullable_model_prop("partition_size", int) + # Logical path of the storage device - path = nullable_model_prop('path', str) - + path = nullable_model_prop("path", str) + # Speed of the storage device (in bytes/s or null if unknown) - speed = nullable_model_prop('speed', int) + speed = nullable_model_prop("speed", int) def __init__(self): super().__init__() diff --git a/src/dsf/py.typed b/src/dsf/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/dsf/utils.py b/src/dsf/utils.py index d1cee10..5e59ea5 100644 --- a/src/dsf/utils.py +++ b/src/dsf/utils.py @@ -5,6 +5,7 @@ from types import UnionType from typing import Any, Optional, Callable, TypeVar, TypeAlias, Union, get_args, get_origin, cast, overload + # We don't want our deprecations to be ignored by default, so create our own type. class DeprecatedWarning(UserWarning): pass @@ -29,34 +30,34 @@ def _matches_type(value: Any, expected_type: Any) -> bool: if len(args) != 2: return True key_type, val_type = args - typed_value = cast(dict[Any, Any], value) - return all(_matches_type(k, key_type) and _matches_type(v, val_type) for k, v in typed_value.items()) + typed_dict = cast(dict[Any, Any], value) + return all(_matches_type(k, key_type) and _matches_type(v, val_type) for k, v in typed_dict.items()) if origin is list: if not isinstance(value, list): return False if len(args) != 1: return True - typed_value = cast(list[Any], value) - return all(_matches_type(item, args[0]) for item in typed_value) + typed_list = cast(list[Any], value) + return all(_matches_type(item, args[0]) for item in typed_list) if origin is tuple: if not isinstance(value, tuple): return False - typed_value = cast(tuple[Any, ...], value) + typed_tuple = cast(tuple[Any, ...], value) if len(args) == 2 and args[1] is Ellipsis: - return all(_matches_type(item, args[0]) for item in typed_value) - if len(args) != len(typed_value): + return all(_matches_type(item, args[0]) for item in typed_tuple) + if len(args) != len(typed_tuple): return False - return all(_matches_type(item, item_type) for item, item_type in zip(typed_value, args)) + return all(_matches_type(item, item_type) for item, item_type in zip(typed_tuple, args)) if origin is set: if not isinstance(value, set): return False if len(args) != 1: return True - typed_value = cast(set[Any], value) - return all(_matches_type(item, args[0]) for item in typed_value) + typed_set = cast(set[Any], value) + return all(_matches_type(item, args[0]) for item in typed_set) try: return isinstance(value, expected_type) @@ -86,29 +87,36 @@ def camel_to_snake(s: str, keep_acronyms: bool = True) -> str: :param keep_acronyms: Wheter acronyms should be kept uppercase or not :returns: The string in snake_case format""" # Added a look-behind (?!^) so initials like SBC are not getting snake-cased - snake = re.sub(r'((?<=[a-z])[A-Z0-9]|(?!^)[A-Z0-9](?=[a-z]))', r'_\1', s) - return '_'.join(w if w.isupper() else w.lower() for w in snake.split('_')) if keep_acronyms else snake.lower() + snake = re.sub(r"((?<=[a-z])[A-Z0-9]|(?!^)[A-Z0-9](?=[a-z]))", r"_\1", s) + return "_".join(w if w.isupper() else w.lower() for w in snake.split("_")) if keep_acronyms else snake.lower() -F = TypeVar('F', bound=Callable[..., Any]) +F = TypeVar("F", bound=Callable[..., Any]) def deprecated(instructions: str) -> Callable[[F], F]: """Flags a function/method as deprecated. :param instructions: A human-friendly string of instructions """ + def decorator(func: F) -> F: """This is a decorator which can be used to mark functions as deprecated. It will result in a warning being emitted when the function is used.""" + def deprecated_func(*args: Any, **kwargs: Any) -> Any: # Do not show DeprecatedWarning on ObjectModel update (function called by update_from_json) frame = inspect.currentframe() if frame is not None and frame.f_back is not None: - if frame.f_back.f_code.co_name not in ['_update_from_json', 'update_from_json']: - warnings.warn(f"Call to deprecated function {func.__name__}(). {instructions}", - DeprecatedWarning, stacklevel=2) + if frame.f_back.f_code.co_name not in ["_update_from_json", "update_from_json"]: + warnings.warn( + f"Call to deprecated function {func.__name__}(). {instructions}", + DeprecatedWarning, + stacklevel=2, + ) return func(*args, **kwargs) + return deprecated_func # type: ignore[return-value] + return decorator # type: ignore[return-value] @@ -118,7 +126,7 @@ def preserve_builtin(data: Optional[JSONObj]) -> JSONObj: to avoid name shadowing. e.g: type => type_""" if data is None: return {} - reserved_keys = ['format', 'global', 'id', 'license', 'max', 'min', 'None', 'type'] + reserved_keys = ["format", "global", "id", "license", "max", "min", "None", "type"] return {f"{k}_" if k in reserved_keys else k: v for k, v in data.items()} @@ -128,5 +136,5 @@ def snake_to_camel(s: str, first_lower: bool = True, keep_acronyms: bool = True) :param first_lower: Wheter the first character is returned as lower case or not :param keep_acronyms: Wheter acronyms should be kept uppercase or not :returns: The string in CamelCase format""" - res = ''.join(w if w.isupper() and keep_acronyms else w.title() for w in s.split('_')) - return f'{res[0].lower()}{res[1:]}' if first_lower and len(res) else res + res = "".join(w if w.isupper() and keep_acronyms else w.title() for w in s.split("_")) + return f"{res[0].lower()}{res[1:]}" if first_lower and len(res) else res diff --git a/tests/test_base_connection.py b/tests/test_base_connection.py index 1b21609..60d4b09 100644 --- a/tests/test_base_connection.py +++ b/tests/test_base_connection.py @@ -1,5 +1,7 @@ +import socket +import time import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch from src.dsf.connections.base_connection import BaseConnection @@ -14,21 +16,43 @@ def test_has_data_available_returns_true_for_complete_buffered_json(self): def test_has_data_available_returns_false_for_partial_buffer_without_socket_data(self): connection = BaseConnection() connection.input = '{"key"' - connection.socket = object() + connection.socket = Mock(spec=socket.socket) - with patch('src.dsf.connections.base_connection.select.select', return_value=([], [], [])): + with patch("src.dsf.connections.base_connection.select.select", return_value=([], [], [])): self.assertFalse(connection.has_data_available()) def test_has_data_available_returns_true_when_socket_is_readable(self): connection = BaseConnection() - connection.socket = object() + connection.socket = Mock(spec=socket.socket) with patch( - 'src.dsf.connections.base_connection.select.select', + "src.dsf.connections.base_connection.select.select", return_value=([connection.socket], [], []), ): self.assertTrue(connection.has_data_available()) + def test_receive_json_returns_first_complete_object_and_buffers_the_rest(self): + connection = BaseConnection() + client, server = socket.socketpair() + with client, server: + connection.socket = client + server.sendall(b'{"key":1}{"key"') + + self.assertEqual(connection.receive_json(), '{"key":1}') + self.assertEqual(connection.input, '{"key"') + + def test_receive_json_raises_when_server_closes_connection(self): + connection = BaseConnection(timeout=30) + client, server = socket.socketpair() + with client: + connection.socket = client + server.close() + + start = time.monotonic() + self.assertRaises(ConnectionError, connection.receive_json) + # Must fail immediately instead of waiting for the timeout + self.assertLess(time.monotonic() - start, 5) + -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_code.py b/tests/test_code.py index 981366a..3922db3 100644 --- a/tests/test_code.py +++ b/tests/test_code.py @@ -28,7 +28,7 @@ def test_code(self): self.assertEqual(c.filePosition, None) self.assertEqual(c.indent, 0) self.assertEqual(c.channel, CodeChannel.HTTP) - self.assertEqual(c.command, 'Code') + self.assertEqual(c.command, "Code") def test_code_keyword(self): json_str = '{"sourceConnection":33,"result":null,"type":"K","channel":"HTTP","lineNumber":null,"indent":0,"keyword":9,"keywordArgument":"test","majorNumber":null,"minorNumber":null,"flags":2048,"comment":null,"filePosition":null,"length":12,"parameters":[],"command":"Code"}' diff --git a/tests/test_commands.py b/tests/test_commands.py index 13e9c99..cedd5d5 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,37 +1,50 @@ import json import unittest +from typing import Any from src.dsf.commands import generic, object_model from src.dsf.connections.init_messages import client_init_messages -def serialize(obj: object) -> dict: +def serialize(obj: object) -> dict[str, Any]: return json.loads(json.dumps(obj, default=lambda o: o.__dict__)) class TestCommands(unittest.TestCase): def test_get_object_model(self): - self.assertEqual(serialize(object_model.get_object_model(["move.axes"])), - {"command": "GetObjectModel", "filters": ["move.axes"]}) + self.assertEqual( + serialize(object_model.get_object_model(["move.axes"])), + {"command": "GetObjectModel", "filters": ["move.axes"]}, + ) def test_query_object_model(self): - self.assertEqual(serialize(object_model.query_object_model("heat", "fn")), - {"command": "QueryObjectModel", "key": "heat", "flags": "fn"}) + self.assertEqual( + serialize(object_model.query_object_model("heat", "fn")), + {"command": "QueryObjectModel", "key": "heat", "flags": "fn"}, + ) def test_set_network_protocol(self): - self.assertEqual(serialize(object_model.set_network_protocol("http", True)), - {"command": "SetNetworkProtocol", "protocol": "http", "enabled": True}) + self.assertEqual( + serialize(object_model.set_network_protocol("http", True)), + {"command": "SetNetworkProtocol", "protocol": "http", "enabled": True}, + ) def test_set_wifi_country(self): - self.assertEqual(serialize(object_model.set_wifi_country("GB")), - {"command": "SetWifiCountry", "countryCode": "GB"}) + self.assertEqual( + serialize(object_model.set_wifi_country("GB")), {"command": "SetWifiCountry", "countryCode": "GB"} + ) def test_set_update_status(self): - self.assertEqual(serialize(generic.set_update_status(True, "Installing", 0.25)), - {"command": "SetUpdateStatus", "updating": True, "message": "Installing", "progress": 0.25}) + self.assertEqual( + serialize(generic.set_update_status(True, "Installing", 0.25)), + {"command": "SetUpdateStatus", "updating": True, "message": "Installing", "progress": 0.25}, + ) def test_subscribe_init_message(self): - message = serialize(client_init_messages.subscribe_init_message( - client_init_messages.SubscriptionMode.PATCH, ["state"], verbose=True, obsolete=True)) + message = serialize( + client_init_messages.subscribe_init_message( + client_init_messages.SubscriptionMode.PATCH, ["state"], verbose=True, obsolete=True + ) + ) self.assertTrue(message["verbose"]) self.assertTrue(message["obsolete"]) diff --git a/tests/test_custom_http_endpoint.py b/tests/test_custom_http_endpoint.py index 3b3c3b7..4d9972e 100644 --- a/tests/test_custom_http_endpoint.py +++ b/tests/test_custom_http_endpoint.py @@ -1,13 +1,10 @@ import threading import os -import pathlib import socket import time -import importlib.util import unittest import json import tempfile -import requests from src.dsf import PROTOCOL_VERSION from src.dsf.connections import CommandConnection @@ -17,7 +14,7 @@ async def respond_something(http_endpoint_connection: HttpEndpointConnection): r = await http_endpoint_connection.read_request() - if (len(r.body) > 0): + if len(r.body) > 0: data = json.loads(r.body) print(data) await http_endpoint_connection.send_response(200, "so happy you asked for it!", HttpResponseType.PlainText) @@ -63,13 +60,14 @@ def mock_dcs(self): http_endpoint_msg = conn.recv(1024) self.assertEqual( - json.loads(http_endpoint_msg.decode()), { + json.loads(http_endpoint_msg.decode()), + { "command": "AddHttpEndpoint", "endpointType": "GET", "namespace": "custom", "path": "getIt", - "isUploadRequest": False - } + "isUploadRequest": False, + }, ) conn.sendall('{"result":"/var/run/dsf/custom/getIt-GET.sock","success":true}'.encode()) diff --git a/tests/test_custom_m_codes.py b/tests/test_custom_m_codes.py index bffceb4..6a96b40 100644 --- a/tests/test_custom_m_codes.py +++ b/tests/test_custom_m_codes.py @@ -1,18 +1,13 @@ import threading import os -import pathlib import socket -import time -import importlib.util import json import unittest import tempfile -from unittest.mock import patch from src.dsf import PROTOCOL_VERSION from src.dsf.connections import InterceptConnection, InterceptionMode from src.dsf.commands.code import CodeType -from src.dsf.object_model import MessageType class TestCustomMCodes(unittest.TestCase): @@ -51,52 +46,55 @@ def mock_dcs(self): setup_msg = conn.recv(1024) self.assertIn(b'"mode":"Intercept"', setup_msg) self.assertIn(b'"interceptionMode":"Pre"', setup_msg) - + conn.sendall(b'{"success":true}') - + # Send M1234 code conn.sendall( b"{" - b'"connection":{"id":12,"apiVersion":' + str(PROTOCOL_VERSION).encode() + b',"isConnected":true},"sourceConnection":12,' + b'"connection":{"id":12,"apiVersion":' + + str(PROTOCOL_VERSION).encode() + + b',"isConnected":true},"sourceConnection":12,' b'"result":null,"type":"M","channel":"HTTP","lineNumber":null,"indent":0,"keyword":0,' b'"keywordArgument":null,"majorNumber":1234,"minorNumber":null,"flags":2048,"comment":null,' b'"filePosition":null,"length":6,"parameters":[],"command":"Code"' b"}" ) - + # Process responses more flexibly response1 = json.loads(conn.recv(1024)) # Either a Flush or Resolve command is acceptable based on implementation self.assertIn("command", response1) - + # Send appropriate response based on what was received if response1.get("command") == "Flush": conn.sendall(b'{"result":true,"success":true}') # After Flush, there should be a Resolve response2 = json.loads(conn.recv(1024)) self.assertEqual(response2.get("command"), "Resolve") - + # Send M5678 code conn.sendall( b"{" - b'"connection":{"id":12,"apiVersion":' + str(PROTOCOL_VERSION).encode() + b',"isConnected":true},"sourceConnection":12,' + b'"connection":{"id":12,"apiVersion":' + + str(PROTOCOL_VERSION).encode() + + b',"isConnected":true},"sourceConnection":12,' b'"result":null,"type":"M","channel":"HTTP","lineNumber":null,"indent":0,"keyword":0,' b'"keywordArgument":null,"majorNumber":5678,"minorNumber":null,"flags":2048,"comment":null,' b'"filePosition":null,"length":6,"parameters":[],"command":"Code"' b"}" ) - + # Expect a response for M5678 response3 = json.loads(conn.recv(1024)) self.assertIn("command", response3) - + conn.close() self.dcs_passed.set() # indicate that all asserts passed and the mock_dcs is shutting down def test_custom_m_codes(self): filters = ["M1234", "M5678"] - intercept_connection = InterceptConnection( - InterceptionMode.PRE, filters=filters, debug=True, timeout=3) + intercept_connection = InterceptConnection(InterceptionMode.PRE, filters=filters, debug=True, timeout=3) intercept_connection.connect(self.mock_dcs_socket_file) while True: # Wait for a code to arrive diff --git a/tests/test_object_model.py b/tests/test_object_model.py index 4048b67..cf1381c 100644 --- a/tests/test_object_model.py +++ b/tests/test_object_model.py @@ -1,17 +1,32 @@ import json import unittest -from typing import Optional - -from src.dsf.object_model import * +from typing import Optional, cast + +from src.dsf.object_model import ( + Accelerometer, + Boards, + BoardState, + DriverId, + ExpansionBoard, + Heater, + InputChannel, + MainBoard, + ObjectModel, + Plugin, + ProbeLoadCell, + ProbeType, +) from src.dsf.object_model.utils import is_model_object, JSONElement, JSONObj, model_prop, nullable_model_prop from src.dsf.object_model.object_model import ModelCollection, ModelDictionary, ModelObject + +class SubModel(ModelObject): + value = model_prop("value", int, 0) + + class TestModelObject(unittest.TestCase): class Dummy(ModelObject): - class SubModel(ModelObject): - value = model_prop("value", int, 0) - p_int = model_prop("p_int", int, 1) np_int = nullable_model_prop("np_int", int) @@ -25,16 +40,22 @@ class SubModel(ModelObject): np_model = nullable_model_prop("np_model", SubModel) p_model_collection = model_prop("p_model_collection", ModelCollection[SubModel], ModelCollection(SubModel)) - p_model_ncollection = model_prop("p_model_ncollection", ModelCollection[Optional[SubModel]], ModelCollection(Optional[SubModel])) - np_model_collection = nullable_model_prop("np_model_collection", ModelCollection[SubModel], lambda sub_model=SubModel: ModelCollection(sub_model)) - np_model_ncollection = nullable_model_prop("np_model_ncollection", ModelCollection[Optional[SubModel]], lambda sub_model=SubModel: ModelCollection(Optional[sub_model])) - + p_model_ncollection = model_prop( + "p_model_ncollection", ModelCollection[Optional[SubModel]], ModelCollection(Optional[SubModel]) + ) + np_model_collection = nullable_model_prop( + "np_model_collection", ModelCollection[SubModel], lambda: ModelCollection(SubModel) + ) + np_model_ncollection = nullable_model_prop( + "np_model_ncollection", ModelCollection[Optional[SubModel]], lambda: ModelCollection(Optional[SubModel]) + ) + def setUp(self): pass def tearDown(self): pass - + def test_update_from_json(self): model = self.Dummy() @@ -46,15 +67,15 @@ def test_update_from_json(self): self.assertIsNone(model.np_str) patch: JSONObj = { - 'p_int': 10, - 'np_int': 11, - 'p_float': 1.1, - 'np_float': 1.2, - 'p_str': "hello", - 'np_str': "world" + "p_int": 10, + "np_int": 11, + "p_float": 1.1, + "np_float": 1.2, + "p_str": "hello", + "np_str": "world", } model.update_from_json(patch) - + self.assertEqual(model.p_int, 10) self.assertEqual(model.np_int, 11) self.assertEqual(model.p_float, 1.1) @@ -67,36 +88,40 @@ def test_update_from_json_bad_data(self): # Test non nullable props - self.assertRaises(ValueError, lambda: model.update_from_json({'p_int': "not an int"})) - self.assertRaises(TypeError, lambda: model.update_from_json({'p_int': None})) - self.assertEqual(model.update_from_json({'p_int': 1.1}).p_int, 1) # float should be cast to int - self.assertEqual(model.update_from_json({'p_int': "2"}).p_int, 2) # str should be cast to int + self.assertRaises(ValueError, lambda: model.update_from_json({"p_int": "not an int"})) + self.assertRaises(TypeError, lambda: model.update_from_json({"p_int": None})) + self.assertEqual(model.update_from_json({"p_int": 1.1}).p_int, 1) # float should be cast to int + self.assertEqual(model.update_from_json({"p_int": "2"}).p_int, 2) # str should be cast to int - self.assertRaises(ValueError, lambda: model.update_from_json({'p_float': "not a float"})) - self.assertRaises(TypeError, lambda: model.update_from_json({'p_float': None})) - self.assertEqual(model.update_from_json({'p_float': 2}).p_float, 2.0) # int should be cast to float - self.assertEqual(model.update_from_json({'p_float': "3.14"}).p_float, 3.14) # str should be cast to float + self.assertRaises(ValueError, lambda: model.update_from_json({"p_float": "not a float"})) + self.assertRaises(TypeError, lambda: model.update_from_json({"p_float": None})) + self.assertEqual(model.update_from_json({"p_float": 2}).p_float, 2.0) # int should be cast to float + self.assertEqual(model.update_from_json({"p_float": "3.14"}).p_float, 3.14) # str should be cast to float - self.assertRaises(TypeError, lambda: model.update_from_json({'p_str': None})) - self.assertEqual(model.update_from_json({'p_str': 123}).p_str, "123") # int should be cast to str - self.assertEqual(model.update_from_json({'p_str': 3.14}).p_str, "3.14") # float should be cast to str + self.assertRaises(TypeError, lambda: model.update_from_json({"p_str": None})) + self.assertEqual(model.update_from_json({"p_str": 123}).p_str, "123") # int should be cast to str + self.assertEqual(model.update_from_json({"p_str": 3.14}).p_str, "3.14") # float should be cast to str # Test nullable props - self.assertRaises(ValueError, lambda: model.update_from_json({'np_int': "not an int"})) - self.assertEqual(model.update_from_json({'np_int': None}).np_int, None) # nullable prop should be set to None - self.assertEqual(model.update_from_json({'np_int': 1.1}).np_int, 1) # float should be cast to int - self.assertEqual(model.update_from_json({'np_int': "2"}).np_int, 2) # str should be cast to int - - self.assertRaises(ValueError, lambda: model.update_from_json({'np_float': "not a float"})) - self.assertEqual(model.update_from_json({'np_float': None}).np_float, None) # nullable prop should be set to None - self.assertEqual(model.update_from_json({'np_float': 2}).np_float, 2.0) # int should be cast to float - self.assertEqual(model.update_from_json({'np_float': "3.14"}).np_float, 3.14) # str should be cast to float - - self.assertEqual(model.update_from_json({'np_str': None}).np_str, None) # nullable prop should be set to None - self.assertEqual(model.update_from_json({'np_str': 123}).np_str, "123") # int should be cast to str - self.assertEqual(model.update_from_json({'np_str': 3.14}).np_str, "3.14") # float should be cast to str - self.assertEqual(model.update_from_json({'np_str': "hello"}).np_str, "hello") # str should be accepted for nullable prop + self.assertRaises(ValueError, lambda: model.update_from_json({"np_int": "not an int"})) + self.assertEqual(model.update_from_json({"np_int": None}).np_int, None) # nullable prop should be set to None + self.assertEqual(model.update_from_json({"np_int": 1.1}).np_int, 1) # float should be cast to int + self.assertEqual(model.update_from_json({"np_int": "2"}).np_int, 2) # str should be cast to int + + self.assertRaises(ValueError, lambda: model.update_from_json({"np_float": "not a float"})) + self.assertEqual( + model.update_from_json({"np_float": None}).np_float, None + ) # nullable prop should be set to None + self.assertEqual(model.update_from_json({"np_float": 2}).np_float, 2.0) # int should be cast to float + self.assertEqual(model.update_from_json({"np_float": "3.14"}).np_float, 3.14) # str should be cast to float + + self.assertEqual(model.update_from_json({"np_str": None}).np_str, None) # nullable prop should be set to None + self.assertEqual(model.update_from_json({"np_str": 123}).np_str, "123") # int should be cast to str + self.assertEqual(model.update_from_json({"np_str": 3.14}).np_str, "3.14") # float should be cast to str + self.assertEqual( + model.update_from_json({"np_str": "hello"}).np_str, "hello" + ) # str should be accepted for nullable prop def test_update_from_json_model_object(self): model = self.Dummy() @@ -104,25 +129,20 @@ def test_update_from_json_model_object(self): self.assertEqual(model.p_model.value, 0) self.assertIsNone(model.np_model) - patch: JSONObj = { - 'p_model': { - 'value': 10 - }, - 'np_model': { - 'value': 20 - } - } + patch: JSONObj = {"p_model": {"value": 10}, "np_model": {"value": 20}} model.update_from_json(patch) self.assertEqual(model.p_model.value, 10) - self.assertIsNotNone(model.np_model) + assert model.np_model is not None self.assertEqual(model.np_model.value, 20) - model.update_from_json({'np_model': None}) + model.update_from_json({"np_model": None}) self.assertIsNone(model.np_model) - self.assertRaises(TypeError, lambda: model.update_from_json({'p_model': None})) # non nullable model prop should not accept None - + self.assertRaises( + TypeError, lambda: model.update_from_json({"p_model": None}) + ) # non nullable model prop should not accept None + def test_update_from_json_model_collection(self): model = self.Dummy() @@ -130,61 +150,79 @@ def test_update_from_json_model_collection(self): self.assertIsNone(model.np_model_collection) patch: JSONObj = { - 'p_model_collection': [{}], - 'p_model_ncollection': [{}, None], - 'np_model_collection': [{}], - 'np_model_ncollection': [{}, None], + "p_model_collection": [{}], + "p_model_ncollection": [{}, None], + "np_model_collection": [{}], + "np_model_ncollection": [{}, None], } model.update_from_json(patch) + assert model.np_model_collection is not None + assert model.np_model_ncollection is not None self.assertEqual(len(model.p_model_collection), 1) self.assertIsNotNone(model.p_model_ncollection) self.assertEqual(len(model.p_model_ncollection), 2) self.assertEqual(len(model.np_model_collection), 1) - self.assertIsNotNone(model.np_model_ncollection) self.assertEqual(len(model.np_model_ncollection), 2) self.assertIsNotNone(model.p_model_collection[0]) self.assertIsNotNone(model.p_model_ncollection[0]) self.assertIsNone(model.p_model_ncollection[1]) - model.update_from_json({'p_model_collection': [{'value': 10}, {'value': 20}], 'np_model_collection': [{'value': 30}, {'value': 40}]}) + model.update_from_json( + { + "p_model_collection": [{"value": 10}, {"value": 20}], + "np_model_collection": [{"value": 30}, {"value": 40}], + } + ) self.assertEqual(len(model.p_model_collection), 2) self.assertEqual(model.p_model_collection[0].value, 10) self.assertEqual(model.p_model_collection[1].value, 20) self.assertEqual(len(model.np_model_collection), 2) self.assertEqual(model.np_model_collection[0].value, 30) self.assertEqual(model.np_model_collection[1].value, 40) - - model.update_from_json({'p_model_ncollection': [{'value': 30}, None, {'value': 40}], 'np_model_ncollection': [{'value': 50}, None, {'value': 60}]}) + + model.update_from_json( + { + "p_model_ncollection": [{"value": 30}, None, {"value": 40}], + "np_model_ncollection": [{"value": 50}, None, {"value": 60}], + } + ) self.assertEqual(len(model.p_model_ncollection), 3) - self.assertEqual(model.p_model_ncollection[0].value, 30) - self.assertIsNone(model.p_model_ncollection[1]) - self.assertEqual(model.p_model_ncollection[2].value, 40) + p_first, p_second, p_third = model.p_model_ncollection + assert p_first is not None and p_third is not None + self.assertEqual(p_first.value, 30) + self.assertIsNone(p_second) + self.assertEqual(p_third.value, 40) self.assertEqual(len(model.np_model_ncollection), 3) - self.assertEqual(model.np_model_ncollection[0].value, 50) - self.assertIsNone(model.np_model_ncollection[1]) - self.assertEqual(model.np_model_ncollection[2].value, 60) + np_first, np_second, np_third = model.np_model_ncollection + assert np_first is not None and np_third is not None + self.assertEqual(np_first.value, 50) + self.assertIsNone(np_second) + self.assertEqual(np_third.value, 60) - model.update_from_json({'p_model_ncollection': [None], 'np_model_ncollection': [None]}) + model.update_from_json({"p_model_ncollection": [None], "np_model_ncollection": [None]}) self.assertEqual(len(model.p_model_ncollection), 1) self.assertIsNone(model.p_model_ncollection[0]) self.assertEqual(len(model.np_model_ncollection), 1) self.assertIsNone(model.np_model_ncollection[0]) - model.update_from_json({'p_model_collection': [], 'p_model_ncollection': []}) + model.update_from_json({"p_model_collection": [], "p_model_ncollection": []}) self.assertEqual(len(model.p_model_collection), 0) self.assertEqual(len(model.p_model_ncollection), 0) - self.assertRaises(TypeError, lambda: model.update_from_json({'p_model_collection': None})) # non nullable model collection should not accept None - self.assertRaises(TypeError, lambda: model.update_from_json({'p_model_ncollection': None})) # non nullable model collection should not accept None - - model.update_from_json({'np_model_collection': None}) # nullable model collection should accept None - model.update_from_json({'np_model_ncollection': None}) # nullable model collection should accept None + self.assertRaises( + TypeError, lambda: model.update_from_json({"p_model_collection": None}) + ) # non nullable model collection should not accept None + self.assertRaises( + TypeError, lambda: model.update_from_json({"p_model_ncollection": None}) + ) # non nullable model collection should not accept None + + model.update_from_json({"np_model_collection": None}) # nullable model collection should accept None + model.update_from_json({"np_model_ncollection": None}) # nullable model collection should accept None self.assertIsNone(model.np_model_collection) self.assertIsNone(model.np_model_ncollection) - - + class TestModelCollection(unittest.TestCase): def setUp(self): @@ -198,7 +236,7 @@ def test_int_list(self): model.update_from_json([1, 2, 3]) self.assertEqual(model, [1, 2, 3]) - + def test_nullable_int_list(self): model: ModelCollection[Optional[int]] = ModelCollection(Optional[int]) @@ -229,7 +267,7 @@ def test_scalar_list_coerces_convertible_values(self): self.assertEqual(model, [2, 3]) self.assertIsInstance(model[0], int) self.assertIsInstance(model[1], int) - + def test_model_object_list(self): model: ModelCollection[Heater] = ModelCollection(Heater) @@ -246,7 +284,7 @@ def test_model_object_list(self): self.assertEqual(len(model), 1) self.assertTrue(is_model_object(model[0])) self.assertEqual(model[0].current, 30) - + def test_nullable_model_object_list(self): model: ModelCollection[Optional[Heater]] = ModelCollection(Optional[Heater]) @@ -256,15 +294,18 @@ def test_nullable_model_object_list(self): self.assertTrue(is_model_object(model[0])) self.assertIsNone(model[1]) self.assertTrue(is_model_object(model[2])) - self.assertEqual(model[0].current, 10) - self.assertEqual(model[2].current, 20) + first, _, third = model + assert first is not None and third is not None + self.assertEqual(first.current, 10) + self.assertEqual(third.current, 20) patch = [None, {}] model.update_from_json(patch) self.assertEqual(len(model), 2) self.assertIsNone(model[0]) self.assertTrue(is_model_object(model[1])) - + + class TestModelDictionary(unittest.TestCase): class Dummy(ModelObject): value = model_prop("value", int, 0) @@ -275,17 +316,32 @@ def setUp(self): def tearDown(self): pass + def test_from_json(self): + model = ModelDictionary.from_json({"key1": 1, "key2": {"nested": "dict"}}) + self.assertIsInstance(model, ModelDictionary) + self.assertEqual(dict(model), {"key1": 1, "key2": {"nested": "dict"}}) + + # Keys matching Python builtins are kept as-is + self.assertEqual(dict(ModelDictionary.from_json({"type": 1})), {"type": 1}) + + # null creates an empty dictionary + self.assertEqual(len(ModelDictionary.from_json(None)), 0) + def test_generic_dict(self): model = ModelDictionary(False) - model.update_from_json({"key1": 1, "key2": "hello", "key3": [1, 2, 3], "key4": {"nested": "dict"}, "key5": None}) + model.update_from_json( + {"key1": 1, "key2": "hello", "key3": [1, 2, 3], "key4": {"nested": "dict"}, "key5": None} + ) self.assertEqual(model["key1"], 1) self.assertEqual(model["key2"], "hello") self.assertEqual(model["key3"], [1, 2, 3]) self.assertEqual(model["key4"], {"nested": "dict"}) self.assertIsNone(model["key5"]) - model.update_from_json({"key1": 2, "key2": "world", "key3": [4, 5], "key4": {"nested": "updated"}, "key5": "not null anymore"}) + model.update_from_json( + {"key1": 2, "key2": "world", "key3": [4, 5], "key4": {"nested": "updated"}, "key5": "not null anymore"} + ) self.assertEqual(model["key1"], 2) self.assertEqual(model["key2"], "world") self.assertEqual(model["key3"], [4, 5]) @@ -302,12 +358,14 @@ def test_generic_dict(self): def test_generic_non_nullable_dict(self): model = ModelDictionary(True) - model.update_from_json({"key1": 1, "key2": "hello", "key3": [1, 2, 3], "key4": {"nested": "dict"}, "key5": None}) + model.update_from_json( + {"key1": 1, "key2": "hello", "key3": [1, 2, 3], "key4": {"nested": "dict"}, "key5": None} + ) self.assertEqual(model["key1"], 1) - self.assertNotIn("key5", model) # non nullable dict should delete the key when set to null + self.assertNotIn("key5", model) # non nullable dict should delete the key when set to null model.update_from_json({"key1": None}) - self.assertNotIn("key1", model) # non nullable dict should delete the key when set to null + self.assertNotIn("key1", model) # non nullable dict should delete the key when set to null def test_model_object_dict(self): model = ModelDictionary(True, self.Dummy) @@ -319,13 +377,48 @@ def test_model_object_dict(self): self.assertEqual(model["item2"].value, 20) model.update_from_json({"item1": {"value": 30}}) - self.assertEqual(model["item1"].value, 30) # item1 should be updated instead of replaced - self.assertEqual(model["item2"].value, 20) # item2 should not be altered + self.assertEqual(model["item1"].value, 30) # item1 should be updated instead of replaced + self.assertEqual(model["item2"].value, 20) # item2 should not be altered model.update_from_json({"item1": None}) self.assertNotIn("item1", model) - self.assertRaises(TypeError, lambda: model.update_from_json({"item1": 1})) # can't update a model object with a non-dict value + self.assertRaises( + TypeError, lambda: model.update_from_json({"item1": 1}) + ) # can't update a model object with a non-dict value + + +class TestDriverId(unittest.TestCase): + def test_defaults(self): + driver = DriverId() + self.assertEqual((driver.board, driver.port), (0, 0)) + self.assertEqual(driver.as_int(), 0) + self.assertEqual(str(driver), "0.0") + + def test_constructors(self): + self.assertEqual(DriverId(as_str="3"), DriverId(board=0, port=3)) + self.assertEqual(DriverId(as_str="1.2"), DriverId(board=1, port=2)) + self.assertEqual(DriverId(as_int=(1 << 16) | 2), DriverId(board=1, port=2)) + self.assertEqual(DriverId(board=1, port=2).as_int(), (1 << 16) | 2) + + def test_update_from_json(self): + driver = DriverId(board=5, port=5) + self.assertIs(driver.update_from_json("1.2"), driver) + self.assertEqual((driver.board, driver.port), (1, 2)) + + # A port-only string refers to the main board + driver.update_from_json("3") + self.assertEqual((driver.board, driver.port), (0, 3)) + self.assertEqual(driver.as_int(), 3) + self.assertEqual(str(driver), "0.3") + + self.assertRaises(TypeError, lambda: driver.update_from_json({"board": 1, "port": 2})) + + def test_equality_and_hash(self): + self.assertEqual(DriverId(board=1, port=2), DriverId(as_str="1.2")) + self.assertNotEqual(DriverId(board=1, port=2), DriverId(board=2, port=1)) + self.assertNotEqual(DriverId(board=1, port=2), "1.2") + self.assertEqual(len({DriverId(board=1, port=2), DriverId(as_str="1.2"), DriverId(board=0, port=2)}), 2) class Model(unittest.TestCase): @@ -345,6 +438,7 @@ def test_instance_defaults_are_isolated(self): m1.update_from_json('{"sbc": {}}') m2.update_from_json('{"sbc": {}}') + assert m1.sbc is not None and m2.sbc is not None self.assertIsNot(m1.sbc.dsf.user_sessions, m2.sbc.dsf.user_sessions) def test_boards(self): @@ -357,6 +451,7 @@ def test_boards(self): json_patch = '{"boards":[{"vIn":{"current":42.5}}]}' model.update_from_json(json_patch) # Check if the value has been modified + assert model.boards[0].v_in is not None self.assertEqual(model.boards[0].v_in.current, 42.5) # Check if other values has not been altered self.assertEqual(model.boards[0].v_in.min, 19.3) @@ -364,37 +459,44 @@ def test_boards(self): def test_boards_main_and_expansion(self): model = ObjectModel() - model.update_from_json('{"boards": [{"name": "Duet 3 MB6HC", "firmwareName": "RepRapFirmware", "maxHeaters": 32},' - ' {"canAddress": 1, "name": "Duet 3 EXP3HC", "state": "timedOut", "timeout": 15}]}') + model.update_from_json( + '{"boards": [{"name": "Duet 3 MB6HC", "firmwareName": "RepRapFirmware", "maxHeaters": 32},' + ' {"canAddress": 1, "name": "Duet 3 EXP3HC", "state": "timedOut", "timeout": 15}]}' + ) self.assertIsInstance(model.boards, Boards) - self.assertIsInstance(model.boards[0], MainBoard) - self.assertIsInstance(model.boards[1], ExpansionBoard) - self.assertEqual(model.boards[0].firmware_name, "RepRapFirmware") - self.assertEqual(model.boards[0].max_heaters, 32) - self.assertEqual(model.boards[1].state, BoardState.timedOut) - self.assertEqual(model.boards[1].timeout, 15) + main_board, expansion_board = model.boards + assert isinstance(main_board, MainBoard) + assert isinstance(expansion_board, ExpansionBoard) + self.assertEqual(main_board.firmware_name, "RepRapFirmware") + self.assertEqual(main_board.max_heaters, 32) + self.assertEqual(expansion_board.state, BoardState.timedOut) + self.assertEqual(expansion_board.timeout, 15) # Boards added by a later patch are typed by their position as well model.update_from_json('{"boards": [{}, {}, {"canAddress": 2}]}') - self.assertIsInstance(model.boards[2], ExpansionBoard) - self.assertEqual(model.boards[2].timeout, 10) + new_board = model.boards[2] + assert isinstance(new_board, ExpansionBoard) + self.assertEqual(new_board.timeout, 10) def test_sensors_accelerometers_and_load_cell(self): model = ObjectModel() - model.update_from_json('{"sensors": {"accelerometers": [null, {"orientation": 25, "port": "121.spi.cs0",' - ' "resolution": 16, "samplingRate": 1344}],' - ' "probes": [{"type": 12, "loadCell": {"force": 12.5, "gramsPerCount": 0.01,' - ' "preload": 50, "preloadWindow": [10, 100]}}]}}') + model.update_from_json( + '{"sensors": {"accelerometers": [null, {"orientation": 25, "port": "121.spi.cs0",' + ' "resolution": 16, "samplingRate": 1344}],' + ' "probes": [{"type": 12, "loadCell": {"force": 12.5, "gramsPerCount": 0.01,' + ' "preload": 50, "preloadWindow": [10, 100]}}]}}' + ) self.assertIsNone(model.sensors.accelerometers[0]) accelerometer = model.sensors.accelerometers[1] - self.assertIsInstance(accelerometer, Accelerometer) + assert isinstance(accelerometer, Accelerometer) self.assertEqual(accelerometer.port, "121.spi.cs0") self.assertEqual(accelerometer.resolution, 16) self.assertEqual(accelerometer.sampling_rate, 1344) probe = model.sensors.probes[0] + assert probe is not None self.assertEqual(probe.type, ProbeType.LoadCell) - self.assertIsInstance(probe.load_cell, ProbeLoadCell) + assert isinstance(probe.load_cell, ProbeLoadCell) self.assertEqual(probe.load_cell.force, 12.5) self.assertEqual(list(probe.load_cell.preload_window), [10.0, 100.0]) @@ -402,12 +504,14 @@ def test_rc2_fields(self): from src.dsf.object_model.move.input_shaping import InputShapingType model = ObjectModel() - model.update_from_json('{"limits": {"reportedAxes": 9},' - ' "move": {"minSpeed": 60, "usingSCurve": true, "currentMove": {"filePosition": 1234},' - ' "axes": [{"phaseStep": true}], "shaping": {"type": "ei2"},' - ' "motionSystems": [{"printingAcceleration": 3000, "userPosition": [1, 2, 3]}]},' - ' "job": {"build": {"objects": [{"cancelled": true}]}},' - ' "sbc": {"upgrade": {"message": "Installing packages", "progress": 0.5}}}') + model.update_from_json( + '{"limits": {"reportedAxes": 9},' + ' "move": {"minSpeed": 60, "usingSCurve": true, "currentMove": {"filePosition": 1234},' + ' "axes": [{"phaseStep": true}], "shaping": {"type": "ei2"},' + ' "motionSystems": [{"printingAcceleration": 3000, "userPosition": [1, 2, 3]}]},' + ' "job": {"build": {"objects": [{"cancelled": true}]}},' + ' "sbc": {"upgrade": {"message": "Installing packages", "progress": 0.5}}}' + ) self.assertEqual(model.limits.reported_axes, 9) self.assertEqual(model.move.min_speed, 60) self.assertTrue(model.move.using_S_curve) @@ -416,7 +520,9 @@ def test_rc2_fields(self): self.assertEqual(model.move.shaping.type, InputShapingType.ei2) self.assertEqual(model.move.motion_systems[0].printing_acceleration, 3000) self.assertEqual(list(model.move.motion_systems[0].user_position), [1.0, 2.0, 3.0]) + assert model.job.build is not None self.assertTrue(model.job.build.objects[0].cancelled) + assert model.sbc is not None and model.sbc.upgrade is not None self.assertEqual(model.sbc.upgrade.message, "Installing packages") self.assertEqual(model.sbc.upgrade.progress, 0.5) @@ -436,38 +542,42 @@ def test_null_clears_dictionary(self): def test_global(self): # "global" is a Python keyword so the JSON key is exposed as ObjectModel.globals - with open('tests/object_model/model_full.json') as fp: + with open("tests/object_model/model_full.json") as fp: json_data = json.load(fp) model = ObjectModel.from_json(json_data) - self.assertEqual(dict(model.globals), json_data['global']) - self.assertEqual(model.globals['daemonTick'], 250) - self.assertEqual(model.globals['nozzleDiameters'], [0.6, 0.4]) - self.assertIsNone(model.globals['ret']) + self.assertEqual(dict(model.globals), json_data["global"]) + self.assertEqual(model.globals["daemonTick"], 250) + self.assertEqual(model.globals["nozzleDiameters"], [0.6, 0.4]) + self.assertIsNone(model.globals["ret"]) # Serialization converts "globals" back to "global" serialized = json.loads(model.to_json()) - self.assertIn('global', serialized) - self.assertNotIn('globals', serialized) - self.assertEqual(serialized['global'], json_data['global']) + self.assertIn("global", serialized) + self.assertNotIn("globals", serialized) + self.assertEqual(serialized["global"], json_data["global"]) # Patch updates, adds and nulls variables (null does not delete global variables) model.update_from_json('{"global":{"daemonTick":500,"newVar":"hello","debug":null}}') - self.assertEqual(model.globals['daemonTick'], 500) - self.assertEqual(model.globals['newVar'], "hello") - self.assertIn('debug', model.globals) - self.assertIsNone(model.globals['debug']) - self.assertEqual(model.globals['lastTool'], -1) + self.assertEqual(model.globals["daemonTick"], 500) + self.assertEqual(model.globals["newVar"], "hello") + self.assertIn("debug", model.globals) + self.assertIsNone(model.globals["debug"]) + self.assertEqual(model.globals["lastTool"], -1) # Global variable names matching reserved keys are kept untouched model.update_from_json('{"global":{"type":1,"global":2}}') - self.assertEqual(model.globals['type'], 1) - self.assertEqual(model.globals['global'], 2) - self.assertNotIn('type_', model.globals) + self.assertEqual(model.globals["type"], 1) + self.assertEqual(model.globals["global"], 2) + self.assertNotIn("type_", model.globals) # Setting the whole object to null clears it model.update_from_json('{"global":null}') self.assertEqual(len(model.globals), 0) + # Anything other than an object or null is rejected + self.assertRaises(TypeError, lambda: model.update_from_json('{"global":[1, 2]}')) + self.assertRaises(TypeError, lambda: model.update_from_json('{"global":5}')) + def test_reserved_keys(self): # JSON keys shadowing Python builtins get a trailing underscore while being unpacked # (see preserve_builtin) and must still map to the correct properties @@ -479,36 +589,40 @@ def test_reserved_keys(self): '{"job":{"file":{"thumbnails":[{"format":"qoi","height":48,"width":48}]}},' '"move":{"axes":[{"letter":"X","max":336,"min":-20.2}],"shaping":{"type":"ei2"}},' '"plugins":{"TestPlugin":{"id":"TestPlugin","license":"MIT"}},' - '"state":{"messageBox":{"max":10.5,"min":-1.5,"message":"test"}}}') + '"state":{"messageBox":{"max":10.5,"min":-1.5,"message":"test"}}}' + ) self.assertEqual(model.job.file.thumbnails[0].format, ThumbnailInfoFormat.QOI) self.assertEqual(model.move.axes[0].max, 336) self.assertEqual(model.move.axes[0].min, -20.2) self.assertEqual(model.move.shaping.type, InputShapingType.ei2) - self.assertEqual(model.plugins['TestPlugin'].id, "TestPlugin") - self.assertEqual(model.plugins['TestPlugin'].license, "MIT") + self.assertEqual(model.plugins["TestPlugin"].id, "TestPlugin") + self.assertEqual(model.plugins["TestPlugin"].license, "MIT") + assert model.state.message_box is not None self.assertEqual(model.state.message_box.max, 10.5) self.assertEqual(model.state.message_box.min, -1.5) # Serialization uses the original JSON key names serialized = json.loads(model.to_json()) - self.assertEqual(serialized['job']['file']['thumbnails'][0]['format'], "qoi") - self.assertEqual(serialized['move']['axes'][0]['max'], 336) - self.assertEqual(serialized['move']['axes'][0]['min'], -20.2) - self.assertEqual(serialized['move']['shaping']['type'], "ei2") - self.assertEqual(serialized['plugins']['TestPlugin']['id'], "TestPlugin") - self.assertEqual(serialized['plugins']['TestPlugin']['license'], "MIT") - self.assertEqual(serialized['state']['messageBox']['max'], 10.5) - self.assertEqual(serialized['state']['messageBox']['min'], -1.5) - for key in ('format_', 'id_', 'license_', 'max_', 'min_', 'type_'): + self.assertEqual(serialized["job"]["file"]["thumbnails"][0]["format"], "qoi") + self.assertEqual(serialized["move"]["axes"][0]["max"], 336) + self.assertEqual(serialized["move"]["axes"][0]["min"], -20.2) + self.assertEqual(serialized["move"]["shaping"]["type"], "ei2") + self.assertEqual(serialized["plugins"]["TestPlugin"]["id"], "TestPlugin") + self.assertEqual(serialized["plugins"]["TestPlugin"]["license"], "MIT") + self.assertEqual(serialized["state"]["messageBox"]["max"], 10.5) + self.assertEqual(serialized["state"]["messageBox"]["min"], -1.5) + for key in ("format_", "id_", "license_", "max_", "min_", "type_"): self.assertNotIn(f'"{key}"', model.to_json()) def test_http_endpoints(self): from src.dsf.object_model import HttpEndpointType + model = ObjectModel() json_patch = '{"sbc":{"dsf":{"httpEndpoints":[{"endpointType":"GET","namespace":"ExecOnMcode","path":"getCmdList","isUploadRequest":false,"unixSocket":"/run/dsf/ExecOnMcode/getCmdList-GET.sock"}]}}}' model.update_from_json(json_patch) + assert model.sbc is not None self.assertEqual(len(model.sbc.dsf.http_endpoints), 1) self.assertEqual(model.sbc.dsf.http_endpoints[0].endpoint_type, HttpEndpointType.GET) @@ -529,26 +643,28 @@ def test_inputs(self): model.update_from_json(json_patch) self.assertEqual(len(model.inputs), 4) - self.assertIsInstance(model.inputs[0], InputChannel) - self.assertIsNone(model.inputs[1]) - self.assertIsInstance(model.inputs[2], InputChannel) - self.assertIsNone(model.inputs[3]) - self.assertEqual(model.inputs[0].name, CodeChannel.HTTP) - self.assertEqual(model.inputs[2].name, CodeChannel.File) - self.assertEqual(model.inputs[2].line_number, 42) + first_channel, second_channel, third_channel, fourth_channel = model.inputs + assert isinstance(first_channel, InputChannel) + self.assertIsNone(second_channel) + assert isinstance(third_channel, InputChannel) + self.assertIsNone(fourth_channel) + self.assertEqual(first_channel.name, CodeChannel.HTTP) + self.assertEqual(third_channel.name, CodeChannel.File) + self.assertEqual(third_channel.line_number, 42) # Existing channels are updated in place, not replaced - first_channel = model.inputs[0] model.update_from_json('{"inputs":[{"state":"executing"},null,{"lineNumber":43},null]}') self.assertIs(model.inputs[0], first_channel) - self.assertEqual(model.inputs[0].state, InputChannelState.executing) - self.assertEqual(model.inputs[2].line_number, 43) + self.assertIs(model.inputs[2], third_channel) + self.assertEqual(first_channel.state, InputChannelState.executing) + self.assertEqual(third_channel.line_number, 43) # A channel may become null, and a previously null one may become a channel model.update_from_json('{"inputs":[null,{"name":"Telnet"},null,null]}') self.assertIsNone(model.inputs[0]) - self.assertIsInstance(model.inputs[1], InputChannel) - self.assertEqual(model.inputs[1].name, CodeChannel.Telnet) + second_channel = model.inputs[1] + assert isinstance(second_channel, InputChannel) + self.assertEqual(second_channel.name, CodeChannel.Telnet) self.assertIsNone(model.inputs[2]) # Helper properties still work @@ -558,28 +674,30 @@ def test_inputs(self): @staticmethod def test_job(): model = ObjectModel() - json_patch = '{"job":{"file":{"filament":[496.4],"fileName":"0:/gcodes/Veil_Token.gcode","generatedBy":"ideaMaker 4.2.1.5321, 2022-10-01 17:45:38 UTC\u002B0200","height":1.04,"lastModified":"2022-10-01T16:45:39+01:00","layerHeight":0.12,"numLayers":9,"printTime":798,"size":594195,"thumbnails":[{"data":"iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAUnklEQVR4nO3cfYwcZ30H8O/zzMy\u002B3d77nl\u002BuztmOA7bjGIOdBBJioDSEJkBLStOWCERTaKS2alWkqqioqpD6BxSVSlUFaktVggRUjdomBVIaikrA4Y9AYkhsY5vEdnz2\u002Bezz3u3e3b7OzPP8\u002BsfM7O5dfMmdb\u002B9s1O9HWu3u3e7s8/xmnmeefZ7fLEBERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERET0/5261gXoljuA7N5cbuADw8PDU8bc6Isa3eJ5W3MaY/NWxiyQ6Xy9o1D0gCPnguBwv8jzvzY5\u002BTMAZq3L\u002BdG\u002BvqFfHRjYXDVmm0Dv2p72bimFZswCfZ2vc4GL/a5TnPTDM0UTHBtynBe/cv78\u002BOPALABZyzI\u002BAKTev2XL1poxb96bzR4oGbPLCAqdr3GgZvIaxZrF\u002BIwJpozIsY1aTz5Vr8/86/R08SWguZZlXC8/bw1E/3ImM3ZTOl24v39ot6OlcNmYnSOOuzWj9Zhv7UBW62FHqfRKNhpYWw1FXihZ84ILPPfVmZmnD1UqZ14GGqsp7Ad7e3ftdDOFd/f37r0Qhrs3uu4tWmGbo9RmT6nMa2\u002Bho4wijcDKBJRMzBjzk4xS4yfqwZGvz5dOfKdeH19NObcBmXv7\u002B/f81tDQnlIY3j7ierc5Su1MKdW/ku0YEb8pMuMpVZwJzZmGmHP9jvPSdBiePV71L/1naer0c8AlAHY15V1P13MDcf9\u002B4\u002BgDe3PpA3PW7negtg05zphWylmPDw9FmpNheOT5pvmdv7h0/shy3vO5jRt/6U2Z7IMN4NYRx9ntKOWtdTkBwIgEZWtOOMDh5xuNRz5\u002B8eJTy3nfp0dGbtqTyf3TJs99i7vCTuVqWREzY8y4hfxUizo6EQTHHpqc\u002BBcA4Xp8/kq517oAS9kDFLbkMl/2Fby840IjGv9YCBwAapVtOxRBAKAR39cB1CBoCDAnghIkXYXcOuXhLQCW1UC2ZHMPV5X6jawCqgBcicqqASgV3etVlltE0ABQhyAQYD6qg1cS7J2B7J1PpXIAnlrOtvqy2V9sOM7bp0TgQpCOy\u002BcAXS\u002B3FUETQAA4Ruvt88D2WbHvuZzyghHgfy4DF1f1AWvkum0gBghqgPgAVDzkFgAi0c1CoBGdq6ObwALQEj03AHwAAQQmfh5K1BCa8d8CAUIIfCzdffnG9Cy3zGVryspxkZQ5OaQ6yy1xXRTaDd7Gz61Erw0A\u002BB3lDgD4EjWKMC53gKihLB6rNMKwvNzyVq3NDTiCGhS0ADXVjvXiciOJL64Q87jsFlHM/bicSYxriMrrx/FuoP1lTwA0oipel67bBnICmC4a89NhR7/RU\u002B3zhQPAU4AjUeG1UlFPF/d8RgFGJD4jALMiKCNuICra2dHWXv17btPaSSvybDkMv7ncMp8Kgie3KvX\u002BYa03uAAcpRZ8SudjF0AWQEopaChoCBwV1TIQgYFCFdFZbTbufTu3caX\u002BvGnMuZLvf3e55X252XxCtL4rB/XOfkcPZSQq81LnCgFajdbGdeiBQkZF\u002B8WN94UPgZXoW3pNAVURVACUICjLwjiE1k7NA9PLLfN6u\u002B6\u002BgxwAvAO5XOGegYH\u002BqtXbJ8Tcf3M285sbtO7zgNZBtFwighqAM2LxsrG1sshpUWjOh\u002BHlukgttPYylJpztJ4GcPbM/Pz4nO9PHWs2X7rKKqjtmczYrb29u/dlMjdWwvCmPq1vvzGVunVIqXQvFFIA1FXUowzBRSu4LNJ80fefvWzN0XwqdWSiWj014ftnj1YqJ3CVM1w3AKP7h4dvvjGdLvRovdUDsr6VMc/RhR7AS2t3REFcLbJjm\u002BPkB5RCdoV1AKLGXxZBSezseL35RI\u002BSxzamUse/MzNz\u002BUeVSum56\u002Bxsci0biP5gPr97fyY/ckdv9pZJP9zd7zqb\u002Bx29o2mlkFaq39N62cOb5QhFalVrjzTF/qDkm6c/O3n\u002B6R8Dl7u1/T1A6s6BgZvv7\u002B3fXxO7f4Pr7nCV2hHPWuW79TkA0BSpiMhkzdpTc8YeySh14ofV\u002Bol/n5k6fgQodetzHsrnR27v7d0/6qbuGXKdNztK7ct0uS6\u002BSCMUuewoTJVDe6Eu5uQG1z17rF4/81i5fOxMozF9Mvq6te7WvIE8DHj3jI7uKAP7tzjeLqtx0IXaup4zUktJZn\u002BKYfiZD54//7WVvv/RTZtGqlq/64ZU6h4ruKPguq9fi3KuVDEMT3gK3zvj\u002B9/7WbX635\u002BZnV1xg3l0dOxdQ57\u002BqyHXfdNalHElRMRMxzNfrqijE37wbI\u002BWo1\u002B\u002BcGH8G9FIes2sdQNRj90w9s\u002BjrvtAt88G3VASi6IITlv52Z\u002BdfXnnSt//lRtu\u002BPwOL/X76asYaqyHkrX2ULPx\u002BU9duPBHK33vf2y/8eSgUq/PAfCuw/oF1laPBsG3P3r\u002B3Aewhguna/4lfUphq6fQkxULr2MKUcU3Hb9OYeXj8oQVac2sJF8gQ8QzVALMQ1AXoBLfz8Tj\u002BdYMkFxdfC8DPSkRpOIp0lRcNzeup1rqdpX1FJFW/Vozc4jG9fMQ1AQoQ6L6Rd\u002B99LzIihb7EnPxdLKLaLo6paLHHgBn8VRw/J5u7c/F9UxmxxoiKMV1nRDpOWvtwFV9wAqsdQMRALUmgM7p2k4qntWIpjmjg9aJ3yhoTylK6z4Kni/tqcQQ0RRugKhRROsb0XRuGP9/LZZujbVVdGy/vey\u002BqJ6CBfNmItFkb3IwJfVLpj7b94IgXqdpN4aongGima1mXM8A3c2T6ayBRTQjFe3Dxf9d\u002BKYF84PS7oSS6WHE5Wzvz/Z0cT1\u002BfYB2Pf14Gr7WXkdpl0ut7fAKWIcziAAXjAhcqFZXI0vcTMe7kgCGglZDMPF/Og96A8BIcgBJ6\u002BzRebAsdX4wIjURGZ8Ngr\u002B5mrqVQ3my37W/3qvVBhfRYlpnp7m4fp0HSNLYk78paZfZR3JQLjwzBvFaQ4D2gfRqDd\u002BKzDZEnrqauh2u1z51UyrzibxWO3NaZVyJG/SiaWDpuL9SfTsbQVK/UBY3wPY\u002B9RGt\u002BST1TOp9pX3oiExi6d3bFWs\u002BuPxQobD59ZnM72aBd/c6zu6s1oMpAJl4ujM6dSt4aJ\u002Buk1tnkGvxesa0CBrxAZIsogFR0P24B/I7/t9asLK20rD2rIgUNXBo1pgTPY5z\u002BKnJyfFL0cL3Vflwf//2kVzu7hHPO\u002BiJ7MxovTOndX8a7d4nOVskS4QeoszJlFJIoz1USSR1DhD1qqV4LaQs7UXCEO0GlBxcgbWzDWvHHeCFWWO\u002BB2O\u002B//Vi8eTV1u2tQO8bN20aE\u002BC2Ta672wH2aqV29yldSDs676E9rExGAUl9BQpGBKJUqzNQAHoB5KGQUapV784GZeI61xEN8yoQXBDBXDxi8K2drxlzGMCTl\u002Br1r/1vuXz2auu3HGvaQA4A3tuGhzfcmU5vnbdq37Z06kBTyX0j2tmsV/F9Y0IEzwT\u002BibKVFw2AmjWXrUgQaF33gGLW8y7XwnDuzPz8eN3a2ulqdXwOmOlu7ZakCsCmsZ6ekd3Z7NimVHZsg6vu3KjUfdscd7AHK1/LSRgRXBLBuNj5s2H4jUlrf1D0/fGLtdrkbK127hQw1d2qLK0PGBpNpws78vmxbel0YcB1\u002B\u002BrGjAiQs9YOp5TyUsoZCWGRh9ryhpS3b1RpvZq6F62dTAv\u002BayoMn3PEPH\u002B60Rh/dGbm0lqunXS1gbwZ6Hvn0NCet\u002Bfzt9dE7dvkOfuMYDSr1Ei3p3RDkYuTgf/Xf3Du3BfORxkk14178/mRB3sHDuRd9Z4R1zvoKbWj2\u002BsgRqTZsPbEnLGHG7CP/\u002BW5c9//CbDsNJP18NG\u002BvqEHh4b\u002BNK/1RzylN3Vz21bENEWKSmFiJgyPOMDhI43Gc1\u002Bemnr\u002BGFDp1uesuIE8APQf3LCh8LpMZvOFwNxycyZ1c9nYPVmtd/U5zmi3CrZcgUhlKgwe88PwH57x/Rc\u002BWyyu64LSA\u002Bgbeu8v9Baswb4tae\u002BOQHDXJs\u002B9bT3LAAC\u002BtbU5Y486GofP\u002B/5TvSIvfHFycuJbwNx6luPhwcH\u002Bd\u002BRyB4Zc9z2DjvtwtzuG12JFTMmYs0m28LgfHO918OKpqpn8t/LFqedWuG7yqg3kvp6eTQezvbsOxivdoylvL4DNAApZrdd8im0lrIgJIadKoXlyyg8f\u002B8jFiaewBl/gfhvI3DC44cBbezNv9ZRzMK2wx9G6kFaqt9uftRqBtTUDTNStnAzEHDru\u002Bz/8\u002BMWLT2ON0sq/uGHLG0Yy\u002BmMbXfdeD2qrXqdU/\u002BUwIsa3tqyUnhJIsRSaIwOuPn6k0XjxC5OTP3y1zINWA3konx95b3//Dqv1XQPafbuI7Cx47uvWpwrd93LT/9svnh//xLe6cGXbh4Ge\u002BzZvuWc45dzf67j3p9e5V\u002ByWpkilFAZP1EJ8db42d\u002BihcnnVQ7J7gfTHbhj7zPZU6o\u002B7UcZroWzMORE8M2PDZ7S1T39zdvbUlyqVywCgPjcy8qa9ufwnc1rdntF6VF/HGb4rMWNt7dH5uYP/WCweXs12HkT/4MPbhh7vc5y3dats14M5a559ZGrqfY9Uq6u6DuOThcL\u002Bu3v7Dg1onetW2a4lC4QNay8YkR\u002BfrQefdm/L5w/ltXPdpYGsVF0ENQguWMEZsThvrb4osurGXs36\u002BWlgVzpeTV7thVrXmolTz58xduxH1qZWu72667qnxerh\u002BKL6rIqn7H9OY6UBN6f1GICxjVm5zT0vUtssticLhdR1mHNzJbMirfWBmTj9IE6taC\u002B8AalCOn1VaRaLzYlARJABkIUgHa/huD8n8ZqL4zMpFhPx44rtTm7B9lSqzwFSNURjWUcEHqKUlBQEXrzu4\u002BHnK15nrMXzYQi3Ya1f0hplCLREOUUu4guToOLLLV\u002BZO3W16xiJzvyp5JYs6vkiqMbpE1WJFglnEDWKOazDT49cqbyI0lf8jqvudHxJbZSf1M7BWpxr1o08rFY5pL0IKuiIGQRVARoQVASYE4uyANMdK9hrISlHolUmAZoq\u002BksrV0ukdWxpxBe8IYrb4uOs8wZ073hLshiCuJxVEdQRdRpFEUyLtFaNayJww45pL4v2gkK0uinxFXgLSVxZwcK8KWBhwDp3TAhBKFF\u002BUeelpBbR6rBBx6Ww8TaSlISw4z3LnZZqWnvyTLl8dJkvX1Idr0znaOWOoR1sif8TX526YGUZ6MhR6sg3S7aBjm0lj9sxlDjPbGH2QCjty4o7L3E1EqVu\u002BGgfDFeigBBa\u002BysIxRUdKhaP3VUYOTekZasDAK9yIAuSPLL28\u002Bg\u002Bjl5HLldnAuSrxa0zRakzncfGcVucVeHHcWtdvowkVenKl1477\u002Brr\u002B5VerW9cnGOzWGdOUCu1IU6YawCt\u002B7q0n1fjvzUQZdI247/XEe3YprRbchgXOAlIZ6Ozi54vWUaRWihyrBaGj5wulf7wB/X6hdd4y2s6FYZz\u002B3pytZyjb3cVeqIfMlje6LozVWZh3NrXlCex6oxbDVGMGgCqiIaOPhbGLdlOiKXz2paKmRWplcLwT75fKj298ogsdDwIKi/Ozn5pIJud11oPWaUKWsFpHeDLjBXwyv19pbglyYtJrJqS/M4AWpdZNxBdqpwM\u002BxqIGkYD7QZqlviszng1rP2u\u002BvPR0Z0DjvN3Gxz37nzHeaydebpwQ7Joo0nCXfL6zkS0zvV/v9XHtndq8trkBwCS1pskIobSfu\u002BVKiAiUjXmuKfU8Wnff8JT6vCpqamXXlhFbtVSPjQyctP2dPrBLPT9w67e3a9U2ot3vY6vdRcAUO2YXSl5r93LyYKzhpH24yQ\u002B0eOOJD8sjFv7\u002BvCFP0wh8fB0cRZzzZijxtqv1YPg248Xi891KzYdUr\u002B3efNeR\u002BQNo573bgXsH3Scm3Iq\u002BhEwpyOZU2FRzLAwbsArz6jJ8QZ0nDWk3evbjr8nI5XW8SYLE1zDeEvJGSTJb0u2VTfmmZlK5cFWg3jf8PBtr0ulDg647jt6tN6RgRrJajWgAc/rGGZ17ujWc5WcBheePtunK1mQeRpIu0EIol/ESM4incMvI9IIrZ0VpSqi1GQ1DMcNMO4AP51oNE6fKZWOn1\u002B/HKuWXmD4/Rs33jnqeXvSSr1lUDtbNWTAgxpOK/Q4SmkH0S\u002BsJGnenb15q9GoOCayuFG0h2wL47ZwqGkk6Q0lGffbhtiyAWpa62IlDE8KcHKm2Tx0qlY78VK9fn6tY7PYENC3t69v596enp2eUnuyWu/q17rgKFXwgMG0UjkPqtdRHUmbi\u002BKW/OrLgs4mPiCtLGwUUawUgrgBSEeMWo2j43hrAIFv7awAMzVrf\u002BIAh8/U6986VCq9ACy9kp69e3Bw5I09PQPlMNziAje6WhcKrru5bm1BATkLFFwRV5Te4Kjkug6B0npQK5WNfpoH4lsz2dm7ATIXiFQCEau0vqSUqqS1nq8bU5kNw/HAmFoITA6m00VrzPzp2dnZS/V65eXrLM9oEWdXb\u002B/A7nR6eGsqlZ81ZhTWjmQ9b6zfcQoudM634YhA9SlBj1LoVUr1JuNpA1Epx9mcNBYLII6btNL5xV4KAWOVqgowl9b6cihSK/n\u002BeN3asmg9vcF1X56oVksXwrB2tFot4trMZyzLRqBnWzY7ONrTk9ueTg8Vw7Cggc09WhdcIDfoeRsq1m50RWkL2egoaCXIi1J9UVJf1IV4UdxU0tEG1l60EBsPo5rW2ul4pbgIpWppxynONJvjVWOmoFR5IJ0\u002Beb5SmZ0ol2eu1XXvRERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERNe3/wOxabcDNmswcQAAAABJRU5ErkJggg==","format":"png","height":200,"offset":483,"size":7144,"width":200}]},"build":{"currentObject":-1,"m486Names":false,"m486Numbers":false,"objects":[]},"duration":0,"pauseDuration":0,"rawExtrusion":0,"warmUpDuration":0,"layers":[],"lastDuration":null}}' + json_patch = '{"job":{"file":{"filament":[496.4],"fileName":"0:/gcodes/Veil_Token.gcode","generatedBy":"ideaMaker 4.2.1.5321, 2022-10-01 17:45:38 UTC\u002b0200","height":1.04,"lastModified":"2022-10-01T16:45:39+01:00","layerHeight":0.12,"numLayers":9,"printTime":798,"size":594195,"thumbnails":[{"data":"iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAUnklEQVR4nO3cfYwcZ30H8O/zzMy\u002b3d77nl\u002buztmOA7bjGIOdBBJioDSEJkBLStOWCERTaKS2alWkqqioqpD6BxSVSlUFaktVggRUjdomBVIaikrA4Y9AYkhsY5vEdnz2\u002bezz3u3e3b7OzPP8\u002bsfM7O5dfMmdb\u002b9s1O9HWu3u3e7s8/xmnmeefZ7fLEBERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERET0/5261gXoljuA7N5cbuADw8PDU8bc6Isa3eJ5W3MaY/NWxiyQ6Xy9o1D0gCPnguBwv8jzvzY5\u002bTMAZq3L\u002bdG\u002bvqFfHRjYXDVmm0Dv2p72bimFZswCfZ2vc4GL/a5TnPTDM0UTHBtynBe/cv78\u002bOPALABZyzI\u002bAKTev2XL1poxb96bzR4oGbPLCAqdr3GgZvIaxZrF\u002bIwJpozIsY1aTz5Vr8/86/R08SWguZZlXC8/bw1E/3ImM3ZTOl24v39ot6OlcNmYnSOOuzWj9Zhv7UBW62FHqfRKNhpYWw1FXihZ84ILPPfVmZmnD1UqZ14GGqsp7Ad7e3ftdDOFd/f37r0Qhrs3uu4tWmGbo9RmT6nMa2\u002bho4wijcDKBJRMzBjzk4xS4yfqwZGvz5dOfKdeH19NObcBmXv7\u002b/f81tDQnlIY3j7ierc5Su1MKdW/ku0YEb8pMuMpVZwJzZmGmHP9jvPSdBiePV71L/1naer0c8AlAHY15V1P13MDcf9\u002b4\u002bgDe3PpA3PW7negtg05zphWylmPDw9FmpNheOT5pvmdv7h0/shy3vO5jRt/6U2Z7IMN4NYRx9ntKOWtdTkBwIgEZWtOOMDh5xuNRz5\u002b8eJTy3nfp0dGbtqTyf3TJs99i7vCTuVqWREzY8y4hfxUizo6EQTHHpqc\u002bBcA4Xp8/kq517oAS9kDFLbkMl/2Fby840IjGv9YCBwAapVtOxRBAKAR39cB1CBoCDAnghIkXYXcOuXhLQCW1UC2ZHMPV5X6jawCqgBcicqqASgV3etVlltE0ABQhyAQYD6qg1cS7J2B7J1PpXIAnlrOtvqy2V9sOM7bp0TgQpCOy\u002bcAXS\u002b3FUETQAA4Ruvt88D2WbHvuZzyghHgfy4DF1f1AWvkum0gBghqgPgAVDzkFgAi0c1CoBGdq6ObwALQEj03AHwAAQQmfh5K1BCa8d8CAUIIfCzdffnG9Cy3zGVryspxkZQ5OaQ6yy1xXRTaDd7Gz61Erw0A\u002bB3lDgD4EjWKMC53gKihLB6rNMKwvNzyVq3NDTiCGhS0ADXVjvXiciOJL64Q87jsFlHM/bicSYxriMrrx/FuoP1lTwA0oipel67bBnICmC4a89NhR7/RU\u002b3zhQPAU4AjUeG1UlFPF/d8RgFGJD4jALMiKCNuICra2dHWXv17btPaSSvybDkMv7ncMp8Kgie3KvX\u002bYa03uAAcpRZ8SudjF0AWQEopaChoCBwV1TIQgYFCFdFZbTbufTu3caX\u002bvGnMuZLvf3e55X252XxCtL4rB/XOfkcPZSQq81LnCgFajdbGdeiBQkZF\u002b8WN94UPgZXoW3pNAVURVACUICjLwjiE1k7NA9PLLfN6u\u002b6\u002bgxwAvAO5XOGegYH\u002bqtXbJ8Tcf3M285sbtO7zgNZBtFwighqAM2LxsrG1sshpUWjOh\u002bHlukgttPYylJpztJ4GcPbM/Pz4nO9PHWs2X7rKKqjtmczYrb29u/dlMjdWwvCmPq1vvzGVunVIqXQvFFIA1FXUowzBRSu4LNJ80fefvWzN0XwqdWSiWj014ftnj1YqJ3CVM1w3AKP7h4dvvjGdLvRovdUDsr6VMc/RhR7AS2t3REFcLbJjm\u002bPkB5RCdoV1AKLGXxZBSezseL35RI\u002bSxzamUse/MzNz\u002bUeVSum56\u002bxsci0biP5gPr97fyY/ckdv9pZJP9zd7zqb\u002bx29o2mlkFaq39N62cOb5QhFalVrjzTF/qDkm6c/O3n\u002b6R8Dl7u1/T1A6s6BgZvv7\u002b3fXxO7f4Pr7nCV2hHPWuW79TkA0BSpiMhkzdpTc8YeySh14ofV\u002bol/n5k6fgQodetzHsrnR27v7d0/6qbuGXKdNztK7ct0uS6\u002bSCMUuewoTJVDe6Eu5uQG1z17rF4/81i5fOxMozF9Mvq6te7WvIE8DHj3jI7uKAP7tzjeLqtx0IXaup4zUktJZn\u002bKYfiZD54//7WVvv/RTZtGqlq/64ZU6h4ruKPguq9fi3KuVDEMT3gK3zvj\u002b9/7WbX635\u002bZnV1xg3l0dOxdQ57\u002bqyHXfdNalHElRMRMxzNfrqijE37wbI\u002bWo1\u002b\u002bcGH8G9FIes2sdQNRj90w9s\u002bjrvtAt88G3VASi6IITlv52Z\u002bdfXnnSt//lRtu\u002bPwOL/X76asYaqyHkrX2ULPx\u002bU9duPBHK33vf2y/8eSgUq/PAfCuw/oF1laPBsG3P3r\u002b3Aewhguna/4lfUphq6fQkxULr2MKUcU3Hb9OYeXj8oQVac2sJF8gQ8QzVALMQ1AXoBLfz8Tj\u002bdYMkFxdfC8DPSkRpOIp0lRcNzeup1rqdpX1FJFW/Vozc4jG9fMQ1AQoQ6L6Rd\u002b99LzIihb7EnPxdLKLaLo6paLHHgBn8VRw/J5u7c/F9UxmxxoiKMV1nRDpOWvtwFV9wAqsdQMRALUmgM7p2k4qntWIpjmjg9aJ3yhoTylK6z4Kni/tqcQQ0RRugKhRROsb0XRuGP9/LZZujbVVdGy/vey\u002bqJ6CBfNmItFkb3IwJfVLpj7b94IgXqdpN4aongGima1mXM8A3c2T6ayBRTQjFe3Dxf9d\u002bKYF84PS7oSS6WHE5Wzvz/Z0cT1\u002bfYB2Pf14Gr7WXkdpl0ut7fAKWIcziAAXjAhcqFZXI0vcTMe7kgCGglZDMPF/Og96A8BIcgBJ6\u002bzRebAsdX4wIjURGZ8Ngr\u002b5mrqVQ3my37W/3qvVBhfRYlpnp7m4fp0HSNLYk78paZfZR3JQLjwzBvFaQ4D2gfRqDd\u002bKzDZEnrqauh2u1z51UyrzibxWO3NaZVyJG/SiaWDpuL9SfTsbQVK/UBY3wPY\u002b9RGt\u002bST1TOp9pX3oiExi6d3bFWs\u002buPxQobD59ZnM72aBd/c6zu6s1oMpAJl4ujM6dSt4aJ\u002buk1tnkGvxesa0CBrxAZIsogFR0P24B/I7/t9asLK20rD2rIgUNXBo1pgTPY5z\u002bKnJyfFL0cL3Vflwf//2kVzu7hHPO\u002biJ7MxovTOndX8a7d4nOVskS4QeoszJlFJIoz1USSR1DhD1qqV4LaQs7UXCEO0GlBxcgbWzDWvHHeCFWWO\u002bB2O\u002b//Vi8eTV1u2tQO8bN20aE\u002bC2Ta672wH2aqV29yldSDs676E9rExGAUl9BQpGBKJUqzNQAHoB5KGQUapV784GZeI61xEN8yoQXBDBXDxi8K2drxlzGMCTl\u002br1r/1vuXz2auu3HGvaQA4A3tuGhzfcmU5vnbdq37Z06kBTyX0j2tmsV/F9Y0IEzwT\u002bibKVFw2AmjWXrUgQaF33gGLW8y7XwnDuzPz8eN3a2ulqdXwOmOlu7ZakCsCmsZ6ekd3Z7NimVHZsg6vu3KjUfdscd7AHK1/LSRgRXBLBuNj5s2H4jUlrf1D0/fGLtdrkbK127hQw1d2qLK0PGBpNpws78vmxbel0YcB1\u002b\u002brGjAiQs9YOp5TyUsoZCWGRh9ryhpS3b1RpvZq6F62dTAv\u002bayoMn3PEPH\u002b60Rh/dGbm0lqunXS1gbwZ6Hvn0NCet\u002bfzt9dE7dvkOfuMYDSr1Ei3p3RDkYuTgf/Xf3Du3BfORxkk14178/mRB3sHDuRd9Z4R1zvoKbWj2\u002bsgRqTZsPbEnLGHG7CP/\u002bW5c9//CbDsNJP18NG\u002bvqEHh4b\u002bNK/1RzylN3Vz21bENEWKSmFiJgyPOMDhI43Gc1\u002bemnr\u002bGFDp1uesuIE8APQf3LCh8LpMZvOFwNxycyZ1c9nYPVmtd/U5zmi3CrZcgUhlKgwe88PwH57x/Rc\u002bWyyu64LSA\u002bgbeu8v9Baswb4tae\u002bOQHDXJs\u002b9bT3LAAC\u002btbU5Y486GofP\u002b/5TvSIvfHFycuJbwNx6luPhwcH\u002bd\u002bRyB4Zc9z2DjvtwtzuG12JFTMmYs0m28LgfHO918OKpqpn8t/LFqedWuG7yqg3kvp6eTQezvbsOxivdoylvL4DNAApZrdd8im0lrIgJIadKoXlyyg8f\u002b8jFiaewBl/gfhvI3DC44cBbezNv9ZRzMK2wx9G6kFaqt9uftRqBtTUDTNStnAzEHDru\u002bz/8\u002bMWLT2ON0sq/uGHLG0Yy\u002bmMbXfdeD2qrXqdU/\u002bUwIsa3tqyUnhJIsRSaIwOuPn6k0XjxC5OTP3y1zINWA3konx95b3//Dqv1XQPafbuI7Cx47uvWpwrd93LT/9svnh//xLe6cGXbh4Ge\u002bzZvuWc45dzf67j3p9e5V\u002byWpkilFAZP1EJ8db42d\u002bihcnnVQ7J7gfTHbhj7zPZU6o\u002b7UcZroWzMORE8M2PDZ7S1T39zdvbUlyqVywCgPjcy8qa9ufwnc1rdntF6VF/HGb4rMWNt7dH5uYP/WCweXs12HkT/4MPbhh7vc5y3dats14M5a559ZGrqfY9Uq6u6DuOThcL\u002bu3v7Dg1onetW2a4lC4QNay8YkR\u002bfrQefdm/L5w/ltXPdpYGsVF0ENQguWMEZsThvrb4osurGXs36\u002bWlgVzpeTV7thVrXmolTz58xduxH1qZWu72667qnxerh\u002bKL6rIqn7H9OY6UBN6f1GICxjVm5zT0vUtssticLhdR1mHNzJbMirfWBmTj9IE6taC\u002b8AalCOn1VaRaLzYlARJABkIUgHa/huD8n8ZqL4zMpFhPx44rtTm7B9lSqzwFSNURjWUcEHqKUlBQEXrzu4\u002bHnK15nrMXzYQi3Ya1f0hplCLREOUUu4guToOLLLV\u002bZO3W16xiJzvyp5JYs6vkiqMbpE1WJFglnEDWKOazDT49cqbyI0lf8jqvudHxJbZSf1M7BWpxr1o08rFY5pL0IKuiIGQRVARoQVASYE4uyANMdK9hrISlHolUmAZoq\u002bksrV0ukdWxpxBe8IYrb4uOs8wZ073hLshiCuJxVEdQRdRpFEUyLtFaNayJww45pL4v2gkK0uinxFXgLSVxZwcK8KWBhwDp3TAhBKFF\u002bUeelpBbR6rBBx6Ww8TaSlISw4z3LnZZqWnvyTLl8dJkvX1Idr0znaOWOoR1sif8TX526YGUZ6MhR6sg3S7aBjm0lj9sxlDjPbGH2QCjty4o7L3E1EqVu\u002bGgfDFeigBBa\u002bysIxRUdKhaP3VUYOTekZasDAK9yIAuSPLL28\u002bg\u002bjl5HLldnAuSrxa0zRakzncfGcVucVeHHcWtdvowkVenKl1477\u002brr\u002b5VerW9cnGOzWGdOUCu1IU6YawCt\u002b7q0n1fjvzUQZdI247/XEe3YprRbchgXOAlIZ6Ozi54vWUaRWihyrBaGj5wulf7wB/X6hdd4y2s6FYZz\u002b3pytZyjb3cVeqIfMlje6LozVWZh3NrXlCex6oxbDVGMGgCqiIaOPhbGLdlOiKXz2paKmRWplcLwT75fKj298ogsdDwIKi/Ozn5pIJud11oPWaUKWsFpHeDLjBXwyv19pbglyYtJrJqS/M4AWpdZNxBdqpwM\u002bxqIGkYD7QZqlviszng1rP2u\u002bvPR0Z0DjvN3Gxz37nzHeaydebpwQ7Joo0nCXfL6zkS0zvV/v9XHtndq8trkBwCS1pskIobSfu\u002bVKiAiUjXmuKfU8Wnff8JT6vCpqamXXlhFbtVSPjQyctP2dPrBLPT9w67e3a9U2ot3vY6vdRcAUO2YXSl5r93LyYKzhpH24yQ\u002b0eOOJD8sjFv7\u002bvCFP0wh8fB0cRZzzZijxtqv1YPg248Xi891KzYdUr\u002b3efNeR\u002bQNo573bgXsH3Scm3Iq\u002bhEwpyOZU2FRzLAwbsArz6jJ8QZ0nDWk3evbjr8nI5XW8SYLE1zDeEvJGSTJb0u2VTfmmZlK5cFWg3jf8PBtr0ulDg647jt6tN6RgRrJajWgAc/rGGZ17ujWc5WcBheePtunK1mQeRpIu0EIol/ESM4incMvI9IIrZ0VpSqi1GQ1DMcNMO4AP51oNE6fKZWOn1\u002b/HKuWXmD4/Rs33jnqeXvSSr1lUDtbNWTAgxpOK/Q4SmkH0S\u002bsJGnenb15q9GoOCayuFG0h2wL47ZwqGkk6Q0lGffbhtiyAWpa62IlDE8KcHKm2Tx0qlY78VK9fn6tY7PYENC3t69v596enp2eUnuyWu/q17rgKFXwgMG0UjkPqtdRHUmbi\u002bKW/OrLgs4mPiCtLGwUUawUgrgBSEeMWo2j43hrAIFv7awAMzVrf\u002bIAh8/U6986VCq9ACy9kp69e3Bw5I09PQPlMNziAje6WhcKrru5bm1BATkLFFwRV5Te4Kjkug6B0npQK5WNfpoH4lsz2dm7ATIXiFQCEau0vqSUqqS1nq8bU5kNw/HAmFoITA6m00VrzPzp2dnZS/V65eXrLM9oEWdXb\u002b/A7nR6eGsqlZ81ZhTWjmQ9b6zfcQoudM634YhA9SlBj1LoVUr1JuNpA1Epx9mcNBYLII6btNL5xV4KAWOVqgowl9b6cihSK/n\u002beN3asmg9vcF1X56oVksXwrB2tFot4trMZyzLRqBnWzY7ONrTk9ueTg8Vw7Cggc09WhdcIDfoeRsq1m50RWkL2egoaCXIi1J9UVJf1IV4UdxU0tEG1l60EBsPo5rW2ul4pbgIpWppxynONJvjVWOmoFR5IJ0\u002beb5SmZ0ol2eu1XXvRERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERNe3/wOxabcDNmswcQAAAABJRU5ErkJggg==","format":"png","height":200,"offset":483,"size":7144,"width":200}]},"build":{"currentObject":-1,"m486Names":false,"m486Numbers":false,"objects":[]},"duration":0,"pauseDuration":0,"rawExtrusion":0,"warmUpDuration":0,"layers":[],"lastDuration":null}}' model.update_from_json(json_patch) def test_json_serialization(self): - with open('tests/object_model/model_full.json') as fp: + with open("tests/object_model/model_full.json") as fp: json_data = json.load(fp) model = ObjectModel.from_json(json_data) - def recursive_compare(obj1, obj2): + def recursive_compare(obj1: object, obj2: object) -> None: if isinstance(obj1, dict): self.assertIsInstance(obj2, dict) - self.assertEqual(set(obj1.keys()), set(obj2.keys())) - for key in obj1: - recursive_compare(obj1[key], obj2[key]) + dict1, dict2 = cast(dict[str, object], obj1), cast(dict[str, object], obj2) + self.assertEqual(set(dict1.keys()), set(dict2.keys())) + for key in dict1: + recursive_compare(dict1[key], dict2[key]) elif isinstance(obj1, list): self.assertIsInstance(obj2, list) - self.assertEqual(len(obj1), len(obj2)) - for item1, item2 in zip(obj1, obj2): + list1, list2 = cast(list[object], obj1), cast(list[object], obj2) + self.assertEqual(len(list1), len(list2)) + for item1, item2 in zip(list1, list2): recursive_compare(item1, item2) elif isinstance(obj1, ModelObject): self.assertIsInstance(obj2, ModelObject) - recursive_compare(obj1.__dict__, obj2.__dict__) + recursive_compare(vars(obj1), vars(obj2)) else: self.assertEqual(obj1, obj2) @@ -601,13 +719,13 @@ def test_messages(self): self.assertEqual(len(model.messages), 0) def test_move_kinematics(self): - from src.dsf.object_model.move.kinematics import CoreKinematics, DeltaKinematics, KinematicsName + from src.dsf.object_model.move.kinematics import CoreKinematics, DeltaKinematics, Kinematics, KinematicsName model = ObjectModel() json_patch = '{"move": {"kinematics": {"name": "delta","deltaRadius": 123}}}' model.update_from_json(json_patch) - self.assertIsInstance(model.move.kinematics, DeltaKinematics) + assert isinstance(model.move.kinematics, DeltaKinematics) self.assertEqual(model.move.kinematics.name, KinematicsName.linearDelta) self.assertEqual(model.move.kinematics.delta_radius, 123) @@ -620,27 +738,65 @@ def test_move_kinematics(self): # Switch to linear delta (eg: M669 K3) json_patch = '{"move":{"kinematics":{"deltaRadius":105.6,"homedHeight":240,"printRadius":80,"towers":[{"angleCorrection":0,"diagonal":215,"endstopAdjustment":0,"xPos":-91.452,"yPos":-52.8},{"angleCorrection":0,"diagonal":215,"endstopAdjustment":0,"xPos":91.452,"yPos":-52.8},{"angleCorrection":0,"diagonal":215,"endstopAdjustment":0,"xPos":0,"yPos":105.6}],"xTilt":0,"yTilt":0,"name":"delta","segmentation":null}}}' model.update_from_json(json_patch) - self.assertIsInstance(model.move.kinematics, DeltaKinematics) + assert isinstance(model.move.kinematics, DeltaKinematics) self.assertEqual(model.move.kinematics.name, KinematicsName.linearDelta) self.assertEqual(model.move.kinematics.delta_radius, 105.6) + # Kinematics without a dedicated class use the base type (eg: M669 K0 on an unconfigured machine) + model.update_from_json('{"move":{"kinematics":{"name":"unknown"}}}') + self.assertIs(type(model.move.kinematics), Kinematics) + self.assertEqual(model.move.kinematics.name, KinematicsName.unknown) + + def test_get_kinematics_type(self): + from src.dsf.object_model.move.kinematics import ( + CoreKinematics, + DeltaKinematics, + HangprinterKinematics, + Kinematics, + KinematicsName, + PolarKinematics, + ScaraKinematics, + ) + + expected_types = { + KinematicsName.cartesian: CoreKinematics, + KinematicsName.coreXY: CoreKinematics, + KinematicsName.markForged: CoreKinematics, + KinematicsName.linearDelta: DeltaKinematics, + KinematicsName.rotaryDelta: Kinematics, + KinematicsName.hangprinter: HangprinterKinematics, + KinematicsName.fiveBarScara: ScaraKinematics, + KinematicsName.scara: ScaraKinematics, + KinematicsName.polar: PolarKinematics, + KinematicsName.unknown: Kinematics, + } + for name, expected_type in expected_types.items(): + kinematics = Kinematics.get_kinematics_type(name) + self.assertIs(type(kinematics), expected_type, name) + self.assertEqual(kinematics.name, name) + + # Names reported by RRF are normalized + self.assertIs(type(Kinematics.get_kinematics_type("Core XY")), CoreKinematics) + self.assertEqual(Kinematics.get_kinematics_type("Rotary Delta").name, KinematicsName.rotaryDelta) + self.assertRaises(ValueError, lambda: Kinematics.get_kinematics_type("not a kinematics")) + def test_plugins(self): model = ObjectModel() self.assertEqual(len(model.plugins), 0) # Plugin installation - json_patch = '{"plugins":{"ExecOnMcode":{"dsfFiles":["execOnMcode.py","http_endpoints.py","MCodeAction.py","__init__.py"],"dwcFiles":["js/ExecOnMcode.09113059.js","js/ExecOnMcode.09113059.js.gz","js/ExecOnMcode.09113059.js.map","js/ExecOnMcode.09113059.js.map.gz"],"sdFiles":["sys/ExecOnMcode/top-example.py"],"pid":-1,"id":"ExecOnMcode","name":"ExecOnMcode","author":"Lo\u00EFc GRENON","version":"0.2","license":"GPL-3.0-or-later","homepage":"https://github.com/LoicGRENON/DSF_ExecOnMcode_Plugin","tags":[],"dwcVersion":"3.4.5","dwcDependencies":[],"sbcRequired":true,"sbcDsfVersion":"3.4.5","sbcExecutable":"execOnMcode.py","sbcExecutableArguments":null,"sbcExtraExecutables":[],"sbcOutputRedirected":true,"sbcPermissions":["commandExecution","codeInterceptionRead","registerHttpEndpoints","fileSystemAccess","launchProcesses"],"sbcPackageDependencies":[],"sbcPythonDependencies":["dsf-python\u003E=3.4.5"],"sbcPluginDependencies":[],"rrfVersion":null,"data":{}}}}' + json_patch = '{"plugins":{"ExecOnMcode":{"dsfFiles":["execOnMcode.py","http_endpoints.py","MCodeAction.py","__init__.py"],"dwcFiles":["js/ExecOnMcode.09113059.js","js/ExecOnMcode.09113059.js.gz","js/ExecOnMcode.09113059.js.map","js/ExecOnMcode.09113059.js.map.gz"],"sdFiles":["sys/ExecOnMcode/top-example.py"],"pid":-1,"id":"ExecOnMcode","name":"ExecOnMcode","author":"Lo\u00efc GRENON","version":"0.2","license":"GPL-3.0-or-later","homepage":"https://github.com/LoicGRENON/DSF_ExecOnMcode_Plugin","tags":[],"dwcVersion":"3.4.5","dwcDependencies":[],"sbcRequired":true,"sbcDsfVersion":"3.4.5","sbcExecutable":"execOnMcode.py","sbcExecutableArguments":null,"sbcExtraExecutables":[],"sbcOutputRedirected":true,"sbcPermissions":["commandExecution","codeInterceptionRead","registerHttpEndpoints","fileSystemAccess","launchProcesses"],"sbcPackageDependencies":[],"sbcPythonDependencies":["dsf-python\u003e=3.4.5"],"sbcPluginDependencies":[],"rrfVersion":null,"data":{}}}}' model.update_from_json(json_patch) self.assertEqual(len(model.plugins), 1) - self.assertIsInstance(model.plugins.get('ExecOnMcode'), Plugin) - self.assertEqual(len(model.plugins['ExecOnMcode'].dsf_files), 4) - self.assertEqual(len(model.plugins['ExecOnMcode'].sbc_permissions), 5) - self.assertEqual(model.plugins['ExecOnMcode'].pid, -1) + self.assertIsInstance(model.plugins.get("ExecOnMcode"), Plugin) + self.assertEqual(len(model.plugins["ExecOnMcode"].dsf_files), 4) + self.assertEqual(len(model.plugins["ExecOnMcode"].sbc_permissions), 5) + self.assertEqual(model.plugins["ExecOnMcode"].pid, -1) # Plugin start json_patch = '{"plugins":{"ExecOnMcode":{"pid":1125}}}' model.update_from_json(json_patch) - self.assertEqual(model.plugins['ExecOnMcode'].pid, 1125) + self.assertEqual(model.plugins["ExecOnMcode"].pid, 1125) # Plugin removal json_patch = '{"plugins":{"ExecOnMcode":null}}' @@ -657,21 +813,56 @@ def test_sensors_filament_monitor(self): json_patch = '{"sensors":{"filamentMonitors":[{"enabled":true,"status":"ok","type":"simple"}]}}' model.update_from_json(json_patch) self.assertEqual(len(model.sensors.filament_monitors), 1) - self.assertEqual(model.sensors.filament_monitors[0].type, FilamentMonitorType.Simple) + filament_monitor = model.sensors.filament_monitors[0] + assert filament_monitor is not None + self.assertEqual(filament_monitor.type, FilamentMonitorType.Simple) # Change filament monitor to Pulsed (rg: M591 D0 P7 C"io2.in" S1) json_patch = '{"sensors":{"filamentMonitors":[{"calibrated":null,"configured":{"mmPerPulse":1,"percentMax":160,"percentMin":60,"sampleDistance":5},"enabled":true,"status":"ok","type":"pulsed"}]}}' model.update_from_json(json_patch) self.assertEqual(len(model.sensors.filament_monitors), 1) - self.assertEqual(model.sensors.filament_monitors[0].type, FilamentMonitorType.Pulsed) + filament_monitor = model.sensors.filament_monitors[0] + assert filament_monitor is not None + self.assertEqual(filament_monitor.type, FilamentMonitorType.Pulsed) + + def test_get_filament_monitor(self): + from src.dsf.object_model.sensors.filament_monitors import ( + FilamentMonitor, + FilamentMonitorType, + LaserFilamentMonitor, + PulsedFilamentMonitor, + RotatingMagnetFilamentMonitor, + ) + + expected_types = { + FilamentMonitorType.Laser: LaserFilamentMonitor, + FilamentMonitorType.Pulsed: PulsedFilamentMonitor, + FilamentMonitorType.RotatingMagnet: RotatingMagnetFilamentMonitor, + FilamentMonitorType.Simple: FilamentMonitor, + FilamentMonitorType.Unknown: FilamentMonitor, + } + for monitor_type, expected_type in expected_types.items(): + # Both the enum and its JSON string value are accepted + for type_ in (monitor_type, monitor_type.value): + monitor = FilamentMonitor.get_filament_monitor(type_) + self.assertIs(type(monitor), expected_type, type_) + self.assertEqual(monitor.type, monitor_type) + + self.assertRaises(ValueError, lambda: FilamentMonitor.get_filament_monitor("not a monitor")) def test_sensors_filament_monitor_rc2_fields(self): + from src.dsf.object_model.sensors.filament_monitors import RotatingMagnetFilamentMonitor + model = ObjectModel() - model.update_from_json('{"sensors":{"filamentMonitors":[{"type":"rotatingMagnet","filamentPresent":true,"agc":120,' - '"calibrated":{"mmPerRev":28.8,"percentMax":110,"percentMin":90,"totalDistance":100}}]}}') + model.update_from_json( + '{"sensors":{"filamentMonitors":[{"type":"rotatingMagnet","filamentPresent":true,"agc":120,' + '"calibrated":{"mmPerRev":28.8,"percentMax":110,"percentMin":90,"totalDistance":100}}]}}' + ) monitor = model.sensors.filament_monitors[0] + assert isinstance(monitor, RotatingMagnetFilamentMonitor) self.assertTrue(monitor.filament_present) self.assertEqual(monitor.agc, 120) + assert monitor.calibrated is not None self.assertEqual(monitor.calibrated.mm_per_rev, 28.8) def test_user_sessions(self): @@ -681,6 +872,7 @@ def test_user_sessions(self): json_patch = '{"sbc": {}}' model.update_from_json(json_patch) + assert model.sbc is not None self.assertEqual(len(model.sbc.dsf.user_sessions), 0) json_patch = '{"sbc":{"dsf":{"userSessions":[{"accessLevel":"readWrite","id":2,"origin":"::ffff:192.168.1.200","originId":-1,"sessionType":"http"}]}}}' @@ -690,5 +882,5 @@ def test_user_sessions(self): self.assertEqual(model.sbc.dsf.user_sessions[0].session_type, SessionType.http) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_send_simple_code.py b/tests/test_send_simple_code.py index de987c4..e0dd8bd 100644 --- a/tests/test_send_simple_code.py +++ b/tests/test_send_simple_code.py @@ -1,17 +1,12 @@ import unittest -from unittest.mock import patch import threading import os -import pathlib import socket import tempfile -import time -import importlib.util import json from src.dsf import PROTOCOL_VERSION from src.dsf.connections import CommandConnection -from tests.utils import check_json class TestSendSimpleCode(unittest.TestCase): @@ -69,11 +64,13 @@ def _run_mock_dcs_server(self): cmd_msg = conn.recv(1024) expected_cmd = { "command": "SimpleCode", - "code": "echo \"Hello world!\"", - "channel": "SBC", "executeAsynchronously": False + "code": 'echo "Hello world!"', + "channel": "SBC", + "executeAsynchronously": False, } - self.assertEqual(json.loads(cmd_msg.decode()), expected_cmd, - f"Incorrect command message: {cmd_msg.decode()}") + self.assertEqual( + json.loads(cmd_msg.decode()), expected_cmd, f"Incorrect command message: {cmd_msg.decode()}" + ) # Send success response for the command conn.sendall('{"result":"Hello world!", "success":true}'.encode()) @@ -91,7 +88,7 @@ def test_send_simple_code(self): # res = command_connection.set_plugin_data("ExecOnMcode", "test", "1") # Perform a simple command and wait for its output - res = command_connection.perform_simple_code("echo \"Hello world!\"") + res = command_connection.perform_simple_code('echo "Hello world!"') self.assertEqual(res, "Hello world!") command_connection.close() diff --git a/tests/test_socket_file.py b/tests/test_socket_file.py new file mode 100644 index 0000000..472818a --- /dev/null +++ b/tests/test_socket_file.py @@ -0,0 +1,37 @@ +import json +import os +import tempfile +import unittest + +from src.dsf import _read_socket_file # pyright: ignore[reportPrivateUsage] + + +class TestSocketFile(unittest.TestCase): + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.config_path = os.path.join(self.tmp_dir.name, "config.json") + + def tearDown(self): + self.tmp_dir.cleanup() + + def test_default_without_config(self): + self.assertEqual(_read_socket_file(self.config_path), "/run/dsf/dcs.sock") + + def test_reads_socket_path_from_config(self): + with open(self.config_path, "w") as f: + json.dump({"SocketDirectory": "/var/run/custom", "SocketFile": "custom.sock"}, f) + self.assertEqual(_read_socket_file(self.config_path), "/var/run/custom/custom.sock") + + def test_missing_config_keys_use_defaults(self): + with open(self.config_path, "w") as f: + json.dump({"SocketFile": "custom.sock"}, f) + self.assertEqual(_read_socket_file(self.config_path), "/run/dsf/custom.sock") + + def test_invalid_config_uses_default(self): + with open(self.config_path, "w") as f: + f.write("{not json") + self.assertEqual(_read_socket_file(self.config_path), "/run/dsf/dcs.sock") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_subscribe_object_model.py b/tests/test_subscribe_object_model.py index 903aa25..80a637f 100644 --- a/tests/test_subscribe_object_model.py +++ b/tests/test_subscribe_object_model.py @@ -1,14 +1,12 @@ import unittest -from unittest.mock import patch +import unittest.mock import threading import os -import pathlib import socket import tempfile import time -import importlib.util import json -from typing import Union, Dict +from typing import cast from tests.utils import check_json from src.dsf import PROTOCOL_VERSION @@ -21,9 +19,9 @@ class TestSubscribeObjectModel(unittest.TestCase): """Test suite for the object model subscription example.""" @staticmethod - def _wait_for_data_available(subscribe_connection: SubscribeConnection, timeout: float = 1.0) -> bool: - deadline = time.time() + timeout - while time.time() < deadline: + def _wait_for_data_available(subscribe_connection: SubscribeConnection, timeout: float = 5.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: if subscribe_connection.has_data_available(): return True time.sleep(0.01) @@ -76,7 +74,7 @@ def _run_mock_dcs_server(self): "mode": "Subscribe", "version": PROTOCOL_VERSION, "subscriptionMode": "Patch", - "filters": [] + "filters": [], } check_json(expected_setup, setup_msg.decode()) @@ -93,14 +91,19 @@ def _run_mock_dcs_server(self): for model_file in model_updates: # Send model data - with open(model_file, 'r') as f: + with open(model_file, "r") as f: update_data = json.load(f) conn.sendall(json.dumps(update_data).encode()) # Verify acknowledge response ack = conn.recv(1024) - self.assertEqual(ack, self.acknowledge_response, - f"Expected acknowledge command, received: {ack.decode()}") + self.assertEqual( + ack, self.acknowledge_response, f"Expected acknowledge command, received: {ack.decode()}" + ) + + # Keep the connection open until the client closes it, like DCS does + conn.settimeout(5) + self.assertEqual(conn.recv(1024), b"", "Expected the client to close the connection") self.dcs_passed.set() # Test completed successfully @@ -188,9 +191,7 @@ def test_subscribe_to_keys_runs_callback_for_matching_changes(self): unsubscribe = subscribe_connection.subscribe_to_keys( ["boards", "heat.heaters.0.current", "state.upTime"], - lambda **kwargs: callback_changes.append( - (kwargs["key"], kwargs["data"], kwargs["indices"]) - ), + lambda key, data, indices: callback_changes.append((key, data, indices)), ) self.assertTrue( @@ -203,8 +204,9 @@ def test_subscribe_to_keys_runs_callback_for_matching_changes(self): self.assertIn(("heat.heaters.0.current", 16.22, None), callback_changes) self.assertIn(("state.upTime", 3658, None), callback_changes) self.assertIn(("boards", unittest.mock.ANY, None), callback_changes) - boards_data = next(data for key, data, indices in callback_changes if key == "boards") - self.assertEqual(len(boards_data), 7) + boards_data = next(data for key, data, _ in callback_changes if key == "boards") + self.assertIsInstance(boards_data, list) + self.assertEqual(len(cast(list[object], boards_data)), 7) unsubscribe() finally: @@ -225,9 +227,7 @@ def test_subscribe_to_keys_passes_wildcard_indexes(self): unsubscribe = subscribe_connection.subscribe_to_keys( ["heat.heaters.^.current", "sensors.analog.^.lastReading"], - lambda **kwargs: callback_changes.append( - (kwargs["key"], kwargs["data"], kwargs["indices"]) - ), + lambda key, data, indices: callback_changes.append((key, data, indices)), ) self.assertTrue( diff --git a/tests/utils.py b/tests/utils.py index 2ebc26f..995710c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,7 +1,8 @@ import json +from typing import Mapping -def check_json(expected_dict: dict[str, object], json_str: str) -> None: +def check_json(expected_dict: Mapping[str, object], json_str: str) -> None: try: json_obj = json.loads(json_str) except json.JSONDecodeError: @@ -11,4 +12,6 @@ def check_json(expected_dict: dict[str, object], json_str: str) -> None: if key not in json_obj: raise AssertionError(f"Key '{key}' not found in JSON {json_str}") if json_obj[key] != expected_value: - raise AssertionError(f"Value for key '{key}' does not match. Expected: {expected_value}, Found: {json_obj[key]}") \ No newline at end of file + raise AssertionError( + f"Value for key '{key}' does not match. Expected: {expected_value}, Found: {json_obj[key]}" + ) diff --git a/tox.ini b/tox.ini index 55b590a..8a03a98 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +1,19 @@ [tox] -envlist = py{36,37,38,39} +envlist = py{311,312,313,314} +# Environments whose Python version is not installed are skipped instead of failing +skip_missing_interpreters = true [testenv] deps = check-manifest - black - mypy + black==26.5.1 + pyright[nodejs]==1.1.414 flake8 pytest commands = - check-manifest --ignore 'tox.ini,docs/**,examples/**,tests/**' + check-manifest python setup.py check -m -s black --check --line-length 120 . - mypy src + pyright flake8 src examples tests pytest