Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion .github/workflows/build-deb-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions .github/workflows/python-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 1 addition & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,5 @@
"-p",
"test_*.py"
],
"debugpy.debugJustMyCode": false,
"python.analysis.typeCheckingMode": "strict"
"debugpy.debugJustMyCode": false
}
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
include LICENSE
include README.md
include setup.py
include src/dsf/py.typed
reverse-include examples/*.py
2 changes: 1 addition & 1 deletion conftest.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
# Used to help pytest discover tests
# Used to help pytest discover tests
9 changes: 6 additions & 3 deletions examples/subscribe_object_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand All @@ -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):
Expand Down
10 changes: 10 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ wheel
build
twine
bump2version
python-dateutil
stdeb
requests
pyright[nodejs]==1.1.414
black==26.5.1
20 changes: 20 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,22 @@
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",
"Programming Language :: Python :: 3.13",
"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": [
Expand Down
33 changes: 19 additions & 14 deletions src/dsf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
35 changes: 33 additions & 2 deletions src/dsf/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
1 change: 1 addition & 0 deletions src/dsf/commands/base_command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Any


class BaseCommand:
"""Base class of a command."""

Expand Down
2 changes: 1 addition & 1 deletion src/dsf/commands/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
return self.flags & flag != 0
2 changes: 1 addition & 1 deletion src/dsf/commands/code_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
13 changes: 7 additions & 6 deletions src/dsf/commands/code_parameter.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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):
Expand Down
5 changes: 3 additions & 2 deletions src/dsf/commands/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion src/dsf/commands/object_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
4 changes: 1 addition & 3 deletions src/dsf/commands/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
5 changes: 5 additions & 0 deletions src/dsf/commands/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
You should have received a copy of the GNU Lesser General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""

from typing import Generic, TypeVar

from ..utils import JSONObj, JSONElement
Expand All @@ -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):
Expand All @@ -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

Expand Down
13 changes: 13 additions & 0 deletions src/dsf/connections/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Loading
Loading