diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 82b4231..9ea4673 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -19,6 +19,20 @@ permissions: contents: read jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: false + - name: Ruff lint + run: uvx ruff@0.15.10 check + - name: Ruff format + run: uvx ruff@0.15.10 format --check + - name: Type check (ty) + run: uvx --with ipython ty@0.0.30 check + linux: runs-on: ${{ matrix.platform.runner }} strategy: @@ -26,19 +40,11 @@ jobs: platform: - runner: ubuntu-latest target: x86_64 - - runner: ubuntu-latest - target: x86 - - runner: ubuntu-latest + - runner: ubuntu-24.04-arm target: aarch64 - - runner: ubuntu-latest - target: armv7 - - runner: ubuntu-latest - target: s390x - - runner: ubuntu-latest - target: ppc64le steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: 3.x - name: Build wheels @@ -48,6 +54,10 @@ jobs: args: --release --out dist --find-interpreter sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} manylinux: auto + before-script-linux: | + if command -v yum >/dev/null; then yum install -y systemd-devel; fi + if command -v apt-get >/dev/null; then apt-get update && apt-get install -y libudev-dev; fi + if command -v apk >/dev/null; then apk add --no-cache eudev-dev; fi - name: Upload wheels uses: actions/upload-artifact@v4 with: @@ -61,15 +71,11 @@ jobs: platform: - runner: ubuntu-latest target: x86_64 - - runner: ubuntu-latest - target: x86 - - runner: ubuntu-latest + - runner: ubuntu-24.04-arm target: aarch64 - - runner: ubuntu-latest - target: armv7 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: 3.x - name: Build wheels @@ -79,6 +85,10 @@ jobs: args: --release --out dist --find-interpreter sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} manylinux: musllinux_1_2 + before-script-linux: | + if command -v yum >/dev/null; then yum install -y systemd-devel; fi + if command -v apt-get >/dev/null; then apt-get update && apt-get install -y libudev-dev; fi + if command -v apk >/dev/null; then apk add --no-cache eudev-dev; fi - name: Upload wheels uses: actions/upload-artifact@v4 with: @@ -95,8 +105,8 @@ jobs: - runner: windows-latest target: x86 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: 3.x architecture: ${{ matrix.platform.target }} @@ -122,8 +132,8 @@ jobs: - runner: macos-latest target: x86_64 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: 3.x - name: Build wheels @@ -141,7 +151,7 @@ jobs: sdist: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Build sdist uses: PyO3/maturin-action@v1 with: @@ -157,7 +167,7 @@ jobs: name: Release runs-on: ubuntu-latest if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} - needs: [linux, musllinux, windows, macos, sdist] + needs: [lint, linux, musllinux, windows, macos, sdist] permissions: # Use to sign the release artifacts id-token: write diff --git a/examples/tl-meta.py b/examples/tl-meta.py index be09e3c..16e857c 100755 --- a/examples/tl-meta.py +++ b/examples/tl-meta.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 +import pprint + import twinleaf dev = twinleaf.Device() meta = dev._get_metadata() -import pprint pp = pprint.PrettyPrinter(indent=1) pp.pprint(meta) diff --git a/examples/tl-samples.py b/examples/tl-samples.py index aa548e8..148de15 100755 --- a/examples/tl-samples.py +++ b/examples/tl-samples.py @@ -1,13 +1,12 @@ #!/usr/bin/env python3 import twinleaf -import pprint dev = twinleaf.Device() -samples_dict_getter = dev.samples # All samples -samples_list_getter = dev.samples.imu.imu.accel # Wildcard samples -#samples_list_getter = dev.samples.imu.imu.accel.x # Specific column +samples_dict_getter = dev.samples # All samples +samples_list_getter = dev.samples.imu.imu.accel # Wildcard samples +# samples_list_getter = dev.samples.imu.imu.accel.x # Specific column samples_dict = samples_dict_getter(n=10) for _id, stream in samples_dict.items(): @@ -19,5 +18,5 @@ samples_list = samples_list_getter(n=10) for sample in samples_list: for column in sample: - print(f"{column:<20}", end='') + print(f"{column:<20}", end="") print() diff --git a/examples/tl-settings.py b/examples/tl-settings.py index 51267ff..f4ba81f 100755 --- a/examples/tl-settings.py +++ b/examples/tl-settings.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 +import pprint + import twinleaf dev = twinleaf.Device() settings = dev.settings() -import pprint pp = pprint.PrettyPrinter(indent=1) pp.pprint(settings) diff --git a/pyproject.toml b/pyproject.toml index 91e0c24..882098e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,3 +36,12 @@ build-backend = "maturin" features = ["pyo3/extension-module"] module-name = "twinleaf._twinleaf" python-source = "python" + +[tool.ruff] +target-version = "py312" + +[tool.ty.environment] +python-version = "3.12" + +[tool.ty.src] +exclude = ["examples"] diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 9f74761..8ff15df 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -1,7 +1,9 @@ -from twinleaf import _twinleaf # type: ignore[attr-defined] +from twinleaf import _twinleaf + class Device(_twinleaf._Device): - """ Primary TIO interface with sensor object """ + """Primary TIO interface with sensor object""" + def __new__(cls, url=None, route=None, announce=False, instantiate=True): device = super().__new__(cls, url, route) return device @@ -14,50 +16,63 @@ def __init__(self, url=None, route=None, announce=False, instantiate=True): def __repr__(self): try: - dev_info = self._rpc('dev.serial', b'').decode() + dev_info = self._rpc("dev.serial", b"").decode() except RuntimeError: - dev_info = '' + dev_info = "" return f"{self.__module__}.{self.__class__.__name__}('{dev_info}', url='{self._url}', route='{self._route}'" - def _rpc_int(self, name: str, size: int, signed: bool, value: int | None = None) -> int: - """ Use struct to send int-typed RPCs """ + def _rpc_int( + self, name: str, size: int, signed: bool, value: int | None = None + ) -> int: + """Use struct to send int-typed RPCs""" import struct + match size, signed: - case 1, True: fstr = ' float: - """ Use struct to send float-typed RPCs """ + """Use struct to send float-typed RPCs""" import struct - fstr = ' dict[int, dict[str, list[int | float]]]: - """ Parse underlying sample iterator into dict """ - if columns is None: columns = [] # Avoid mutable default + def _samples_dict( + self, n: int = 1, stream: str = "", columns: list[str] | None = None + ) -> dict[int, dict[str, list[int | float]]]: + """Parse underlying sample iterator into dict""" + if columns is None: + columns = [] # Avoid mutable default samples = list(self._samples(n, stream=stream, columns=columns)) # Bin into streams streams = {} for line in samples: stream_id = line.pop("stream", None) if stream_id not in streams: - streams[stream_id] = { "stream": stream_id } + streams[stream_id] = {"stream": stream_id} for key, value in line.items(): if key not in streams[stream_id]: streams[stream_id][key] = [] streams[stream_id][key].append(value) return streams - def _samples_list(self, n: int = 1, stream: str = "", columns: list[str] | None=None, time_column=True, title_row=True) -> list[list[str | int | float]]: - """ Parse underlying sample iterator into tabular array """ - if columns is None: columns = [] # Avoid mutable default + def _samples_list( + self, + n: int = 1, + stream: str = "", + columns: list[str] | None = None, + time_column=True, + title_row=True, + ) -> list[list[str | int | float]]: + """Parse underlying sample iterator into tabular array""" + if columns is None: + columns = [] # Avoid mutable default streams = self._samples_dict(n, stream, columns) - # Convert to list with rows of data. Not super happy about how inefficient this is. + # Convert to list with rows of data. Not super happy about how inefficient this is. if len(streams.items()) > 1: - raise NotImplementedError("Stream concatenation not yet implemented for two different streams") + raise NotImplementedError( + "Stream concatenation not yet implemented for two different streams" + ) stream_dict = list(streams.values())[0] - stream_dict.pop('stream') + stream_dict.pop("stream") if not time_column: - stream_dict.pop('time') - data_columns = [column for column in stream_dict.values() ] + stream_dict.pop("time") + data_columns = [column for column in stream_dict.values()] data_rows = [list(row) for row in zip(*data_columns)] if title_row: column_names = list(stream_dict.keys()) - data_rows.insert(0,column_names) + data_rows.insert(0, column_names) return data_rows - def _instantiate_samples(self, announce: bool=False): + def _instantiate_samples(self, announce: bool = False): metadata = self._get_metadata() if announce: - dev_meta = metadata['device'] - print(f"{dev_meta['name']} ({dev_meta['serial_number']}) [{dev_meta['firmware_hash']}]") + dev_meta = metadata["device"] + print( + f"{dev_meta['name']} ({dev_meta['serial_number']}) [{dev_meta['firmware_hash']}]" + ) streams_flattened = [] - for stream, value in metadata['streams'].items(): - for column_name in value['columns'].keys(): - streams_flattened.append(stream+"."+column_name) + for stream, value in metadata["streams"].items(): + for column_name in value["columns"].keys(): + streams_flattened.append(stream + "." + column_name) # All samples self.samples = _SamplesDict(self, "samples", stream="", columns=[]) @@ -120,7 +150,11 @@ def _instantiate_samples(self, announce: bool=False): # All samples for this stream if not hasattr(parent, stream): - setattr(parent, stream, _SamplesList(self, name=stream, stream=stream, columns=[])) + setattr( + parent, + stream, + _SamplesList(self, name=stream, stream=stream, columns=[]), + ) parent = getattr(parent, stream) # Wildcard columns within stream @@ -128,34 +162,47 @@ def _instantiate_samples(self, announce: bool=False): for token in prefix: stream_prefix += "." + token if not hasattr(parent, token): - setattr(parent, token, _SamplesList(self, token, stream, columns=[stream_prefix[1:]+".*"])) + setattr( + parent, + token, + _SamplesList( + self, token, stream, columns=[stream_prefix[1:] + ".*"] + ), + ) parent = getattr(parent, token) # Specific stream samples - stream, column_name = stream_column.split(".",1) - setattr(parent, mname, _SamplesList(self, mname, stream, columns=[column_name])) + stream, column_name = stream_column.split(".", 1) + setattr( + parent, mname, _SamplesList(self, mname, stream, columns=[column_name]) + ) def _interact(self): imported_objects = {} imported_objects["tl"] = self try: import IPython + IPython.embed( - user_ns=imported_objects, - banner1="", - banner2="", # Use : {self._shortname}. + user_ns=imported_objects, + banner1="", + banner2="", # Use : {self._shortname}. exit_msg="", - enable_tip=False) + enable_tip=False, + ) except ImportError: import code + repl = code.InteractiveConsole(locals=imported_objects) - repl.interact( - banner = "", - exitmsg = "") + repl.interact(banner="", exitmsg="") + type _rpc_type = int | float | str | bytes | None + + class _RpcNode: - """ Base class for RPCs and surveys in the device tree """ + """Base class for RPCs and surveys in the device tree""" + def __init__(self, name: str): self.__name__ = name @@ -163,33 +210,37 @@ def __repr__(self): return f"{self.__module__}.{self.__class__.__name__}('{self.__name__}')" def _survey(self) -> dict[str, _rpc_type]: - """ Recursively collect all readable RPC values in this subtree """ + """Recursively collect all readable RPC values in this subtree""" results = {} for name, attr in self.__dict__.items(): if isinstance(attr, _RpcNode): # Check if it's an RPC that should be read if isinstance(attr, _Rpc): - if attr._readable and attr._type not in { None, bytes }: + if attr._readable and attr._type not in {None, bytes}: results[attr.__name__] = attr._call() # Recursively survey children (works for both Rpc and Survey) results |= attr._survey() return results + class _RpcSurvey(_RpcNode): - """" Branch object that can collect all callable child RPC values """ + """ " Branch object that can collect all callable child RPC values""" + def __init__(self, name: str): super().__init__(name) def __call__(self) -> dict[str, _rpc_type]: return self._survey() + class _Rpc(_RpcNode): - """ Base class for RPCs """ + """Base class for RPCs""" + def __new__(cls, pyrpc: _twinleaf._Rpc, device: Device): match pyrpc: - case r if r.type_str == '' and r.size_bytes != 0: - subclass = _RpcReadWrite # unknown/bytes rpc + case r if r.type_str == "" and r.size_bytes != 0: + subclass = _RpcReadWrite # unknown/bytes rpc case r if r.readable and r.writable: subclass = _RpcReadWrite case r if r.writable: @@ -205,83 +256,100 @@ def __init__(self, pyrpc: _twinleaf._Rpc, device: Device): super().__init__(pyrpc.name) self._device = device self._data_size = pyrpc.size_bytes - self._readable = pyrpc.readable - self._writable = pyrpc.writable + self._readable = pyrpc.readable + self._writable = pyrpc.writable self._type: type | None = None match pyrpc.type_str: - case t if t.startswith('u'): + case t if t.startswith("u"): self._type = int self._data_type = 0 self._signed = False - case t if t.startswith('i'): + case t if t.startswith("i"): self._type = int self._data_type = 1 self._signed = True - case t if t.startswith('f'): + case t if t.startswith("f"): self._type = float self._data_type = 2 - case t if t.startswith('s'): + case t if t.startswith("s"): self._type = str self._data_type = 3 - case '' if self._data_size == 0: + case "" if self._data_size == 0: self._type = None self._data_type = 0 - case other: + case _: self._type = bytes self._data_type = 0 def __repr__(self): - ret = super().__repr__().strip(')') + ", " - if hasattr(self, '_signed') and not self._signed: + ret = super().__repr__().strip(")") + ", " + if hasattr(self, "_signed") and not self._signed: ret += "u" - ret += self._type.__name__ - if self._data_size: # is not 0 or None - ret += str(self._data_size*8) - ret += ')' + ret += self._type.__name__ if self._type is not None else "none" + if self._data_size: # is not 0 or None + ret += str(self._data_size * 8) + ret += ")" return ret - def _call(self, arg: _rpc_type=None) -> _rpc_type: + def _call(self, arg: _rpc_type = None) -> _rpc_type: match self._type: case t if t is int: assert arg is None or isinstance(arg, int) - return self._device._rpc_int(self.__name__, self._data_size, self._signed, arg) + assert self._data_size is not None + return self._device._rpc_int( + self.__name__, self._data_size, self._signed, arg + ) case t if t is float: try: value = None if arg is None else float(arg) except (TypeError, ValueError) as err: - raise TypeError(f"{self.__name__} expects a float-compatible value") from err + raise TypeError( + f"{self.__name__} expects a float-compatible value" + ) from err + assert self._data_size is not None return self._device._rpc_float(self.__name__, self._data_size, value) case t if t is str: - if arg is None: arg = '' + if arg is None: + arg = "" assert isinstance(arg, str) return self._device._rpc(self.__name__, arg.encode()).decode() case t if t is bytes: - if arg is None: arg = b'' + if arg is None: + arg = b"" + assert isinstance(arg, bytes) return self._device._rpc(self.__name__, arg) case None: - return self._device._rpc(self.__name__, b'') + return self._device._rpc(self.__name__, b"") case other: - raise TypeError(f"Invalid RPC type {other}, RPC types must be {_rpc_type}") + raise TypeError( + f"Invalid RPC type {other}, RPC types must be {_rpc_type}" + ) + class _RpcReadOnly(_Rpc): def __call__(self): return self._call() + class _RpcWriteOnly(_Rpc): def __call__(self, arg): return self._call(arg) + class _RpcReadWrite(_Rpc): def __call__(self, arg=None): return self._call(arg) + class _RpcAction(_Rpc): def __call__(self) -> None: - return self._call() + self._call() + # Samples classes class _SamplesBase: - """ Base class for sample objects """ + """Base class for sample objects""" + def __init__(self, device: Device, name: str, stream: str, columns: list[str]): self._device = device self.__name__ = name @@ -291,18 +359,36 @@ def __init__(self, device: Device, name: str, stream: str, columns: list[str]): def __repr__(self): return f"{self.__module__}.{self.__class__.__name__}('{self.__name__}', stream='{self._stream}', columns={self._columns})" + class _SamplesDict(_SamplesBase): - """ Returns samples as dict keyed by stream_id """ - def __init__(self, device: Device, name: str, stream: str="", columns: list[str] | None=None): - super().__init__(device, name, stream, columns if columns is not None else [] ) + """Returns samples as dict keyed by stream_id""" - def __call__(self, n: int=1, *, time_column=True, title_row=True): - return self._device._samples_dict(n, self._stream, self._columns, time_column=time_column, title_row=title_row) + def __init__( + self, + device: Device, + name: str, + stream: str = "", + columns: list[str] | None = None, + ): + super().__init__(device, name, stream, columns if columns is not None else []) + + def __call__(self, n: int = 1): + return self._device._samples_dict(n, self._stream, self._columns) -class _SamplesList(_SamplesBase): - """ Returns samples as list for single stream """ - def __init__(self, device: Device, name: str, stream: str="", columns: list[str] | None=None): - super().__init__(device, name, stream, columns if columns is not None else [] ) - def __call__(self, n: int=1, *, time_column=True, title_row=True): - return self._device._samples_list(n, self._stream, self._columns, time_column=time_column, title_row=title_row) +class _SamplesList(_SamplesBase): + """Returns samples as list for single stream""" + + def __init__( + self, + device: Device, + name: str, + stream: str = "", + columns: list[str] | None = None, + ): + super().__init__(device, name, stream, columns if columns is not None else []) + + def __call__(self, n: int = 1, *, time_column=True, title_row=True): + return self._device._samples_list( + n, self._stream, self._columns, time_column=time_column, title_row=title_row + ) diff --git a/python/twinleaf/_twinleaf.pyi b/python/twinleaf/_twinleaf.pyi new file mode 100644 index 0000000..67e6df3 --- /dev/null +++ b/python/twinleaf/_twinleaf.pyi @@ -0,0 +1,49 @@ +"""Type stub for the Rust extension module (twinleaf._twinleaf). + +Mirrors the pyo3 bindings in rust/src/lib.rs. Keep in sync with the +`#[pymethods]`/`#[getter]` definitions there. +""" + +from collections.abc import Iterator +from typing import Any + +class _Rpc: + @property + def name(self) -> str: ... + @property + def readable(self) -> bool: ... + @property + def writable(self) -> bool: ... + @property + def size_bytes(self) -> int | None: ... + @property + def type_str(self) -> str: ... + def __repr__(self) -> str: ... + +class _RpcRegistry: + def children_of(self, prefix: str) -> list[str]: ... + def find(self, name: str) -> _Rpc | None: ... + def suggest(self, query: str) -> list[str]: ... + def search(self, query: str) -> list[str]: ... + @property + def hash(self) -> int | None: ... + def __repr__(self) -> str: ... + +class _Device: + def __new__( + cls, root_url: str | None = ..., route: str | None = ... + ) -> _Device: ... + @property + def _url(self) -> str: ... + @property + def _route(self) -> str: ... + def _rpc(self, name: str, req: bytes) -> bytes: ... + def _rpc_list(self) -> list[Any]: ... + def _rpc_registry(self) -> _RpcRegistry: ... + def _samples( + self, + n: int | None = ..., + stream: str | None = ..., + columns: list[str] | None = ..., + ) -> Iterator[dict[str, Any]]: ... + def _get_metadata(self) -> dict[str, Any]: ... diff --git a/python/twinleaf/itl.py b/python/twinleaf/itl.py index bd0db6b..c189394 100755 --- a/python/twinleaf/itl.py +++ b/python/twinleaf/itl.py @@ -1,17 +1,18 @@ #!/usr/bin/env python from . import Device -def interact(url: str = 'tcp://localhost'): + + +def interact(url: str = "tcp://localhost"): import argparse - parser = argparse.ArgumentParser(prog='itl', - description='Interactive Twinleaf I/O.') - - parser.add_argument("url", - nargs='?', - default='tcp://localhost', - help='URL: tcp://localhost') - parser.add_argument("-s", - default='', - help='Routing: /0/1...') + + parser = argparse.ArgumentParser( + prog="itl", description="Interactive Twinleaf I/O." + ) + + parser.add_argument( + "url", nargs="?", default="tcp://localhost", help="URL: tcp://localhost" + ) + parser.add_argument("-s", default="", help="Routing: /0/1...") args = parser.parse_args() dev = Device(url=args.url, route=args.s, announce=True) @@ -19,5 +20,6 @@ def interact(url: str = 'tcp://localhost'): dev._interact() + if __name__ == "__main__": - interact() + interact() diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 29ab2fa..92525ce 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "twinleaf-python" -version = "0.2.2" +version = "0.2.3" dependencies = [ "crossbeam", "pyo3", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 38e168f..a025976 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "twinleaf-python" -version = "0.2.2" +version = "0.2.3" edition = "2021" [lib]