From 5e202dc761efc694899057772c84bc573126786f Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Mon, 1 Jun 2026 12:54:37 -0400 Subject: [PATCH 01/10] fix: don't forward time_column/title_row to _samples_dict --- python/twinleaf/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 9f74761..36a336e 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -296,8 +296,8 @@ class _SamplesDict(_SamplesBase): 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_dict(n, self._stream, self._columns, time_column=time_column, title_row=title_row) + 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 """ From 5157364010a751e3c57d90ff36af5694b165341f Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Mon, 1 Jun 2026 12:54:50 -0400 Subject: [PATCH 02/10] feat(typing): add type stub for the _twinleaf extension module --- python/twinleaf/__init__.py | 2 +- python/twinleaf/_twinleaf.pyi | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 python/twinleaf/_twinleaf.pyi diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 36a336e..4e2ee4b 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -1,4 +1,4 @@ -from twinleaf import _twinleaf # type: ignore[attr-defined] +from twinleaf import _twinleaf class Device(_twinleaf._Device): """ Primary TIO interface with sensor object """ diff --git a/python/twinleaf/_twinleaf.pyi b/python/twinleaf/_twinleaf.pyi new file mode 100644 index 0000000..897b347 --- /dev/null +++ b/python/twinleaf/_twinleaf.pyi @@ -0,0 +1,47 @@ +"""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]: ... From 8a55bb31e19b7880997e1943ac3713296661aa8d Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Mon, 1 Jun 2026 12:55:31 -0400 Subject: [PATCH 03/10] fix: tighten RPC type handling for None type and reply types --- python/twinleaf/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 4e2ee4b..954b1b8 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -226,7 +226,7 @@ def __init__(self, pyrpc: _twinleaf._Rpc, device: Device): case '' if self._data_size == 0: self._type = None self._data_type = 0 - case other: + case _: self._type = bytes self._data_type = 0 @@ -234,7 +234,7 @@ def __repr__(self): ret = super().__repr__().strip(')') + ", " if hasattr(self, '_signed') and not self._signed: ret += "u" - ret += self._type.__name__ + 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 += ')' @@ -244,12 +244,14 @@ 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) + 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 + 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 = '' @@ -257,6 +259,7 @@ def _call(self, arg: _rpc_type=None) -> _rpc_type: return self._device._rpc(self.__name__, arg.encode()).decode() case t if t is bytes: 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'') @@ -277,7 +280,7 @@ def __call__(self, arg=None): class _RpcAction(_Rpc): def __call__(self) -> None: - return self._call() + self._call() # Samples classes class _SamplesBase: From dcc790e74c5ac47fc81d101b0c1c0010220423bc Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Mon, 1 Jun 2026 12:55:42 -0400 Subject: [PATCH 04/10] chore(examples): clean up imports --- examples/tl-meta.py | 3 ++- examples/tl-samples.py | 1 - examples/tl-settings.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) 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..c7b1d16 100755 --- a/examples/tl-samples.py +++ b/examples/tl-samples.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import twinleaf -import pprint dev = twinleaf.Device() 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) From dbb9ddd2a00574d09492ea2535bfb8cee5247edc Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Mon, 1 Jun 2026 12:55:42 -0400 Subject: [PATCH 05/10] build: configure ruff and ty --- pyproject.toml | 9 +++++++++ 1 file changed, 9 insertions(+) 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"] From 3c243da60696349adef09533833ecff8b0f770d7 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Mon, 1 Jun 2026 12:55:42 -0400 Subject: [PATCH 06/10] ci: lint and type-check with ruff and ty --- .github/workflows/CI.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 82b4231..183e7f2 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -19,6 +19,18 @@ permissions: contents: read jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - 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 ty@0.0.30 check + linux: runs-on: ${{ matrix.platform.runner }} strategy: @@ -157,7 +169,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 From 284dd9e5d270ce461f7014a0e6fae201c86775db Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Mon, 1 Jun 2026 12:56:05 -0400 Subject: [PATCH 07/10] style: format with ruff --- examples/tl-samples.py | 8 +- python/twinleaf/__init__.py | 267 ++++++++++++++++++++++------------ python/twinleaf/_twinleaf.pyi | 4 +- python/twinleaf/itl.py | 26 ++-- 4 files changed, 196 insertions(+), 109 deletions(-) diff --git a/examples/tl-samples.py b/examples/tl-samples.py index c7b1d16..148de15 100755 --- a/examples/tl-samples.py +++ b/examples/tl-samples.py @@ -4,9 +4,9 @@ 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(): @@ -18,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/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 954b1b8..8ff15df 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -1,7 +1,9 @@ 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,25 +256,25 @@ 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 _: @@ -231,60 +282,74 @@ def __init__(self, pyrpc: _twinleaf._Rpc, device: Device): 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._type is not None else "none" - if self._data_size: # is not 0 or None - ret += str(self._data_size*8) - ret += ')' + 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) assert self._data_size is not None - return self._device._rpc_int(self.__name__, self._data_size, self._signed, arg) + 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: 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 @@ -294,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 [] ) - def __call__(self, n: int=1): +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 []) + + 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 index 897b347..67e6df3 100644 --- a/python/twinleaf/_twinleaf.pyi +++ b/python/twinleaf/_twinleaf.pyi @@ -30,7 +30,9 @@ class _RpcRegistry: def __repr__(self) -> str: ... class _Device: - def __new__(cls, root_url: str | None = ..., route: str | None = ...) -> _Device: ... + def __new__( + cls, root_url: str | None = ..., route: str | None = ... + ) -> _Device: ... @property def _url(self) -> str: ... @property 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() From 9e6861e3dadd9a4a88ab02d9bf64be03584999dc Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Mon, 1 Jun 2026 13:19:16 -0400 Subject: [PATCH 08/10] chore: bump version to 0.2.3 --- rust/Cargo.lock | 2 +- rust/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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] From cd63c0f5fc3075b19672c47aad9998e28fa76c8f Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Mon, 1 Jun 2026 13:19:16 -0400 Subject: [PATCH 09/10] ci: fix linux builds (libudev), ty deps (ipython), and node 24 actions --- .github/workflows/CI.yml | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 183e7f2..6fa4eb6 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -22,14 +22,14 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@v5 - 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 ty@0.0.30 check + run: uvx --with ipython ty@0.0.30 check linux: runs-on: ${{ matrix.platform.runner }} @@ -49,8 +49,8 @@ jobs: - 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 @@ -60,6 +60,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: @@ -80,8 +84,8 @@ jobs: - 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 @@ -91,6 +95,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: @@ -107,8 +115,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 }} @@ -134,8 +142,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 @@ -153,7 +161,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: From 5c4850932746a6c1d147d56bc1ceaa556b145113 Mon Sep 17 00:00:00 2001 From: Steven Nguyen Date: Mon, 1 Jun 2026 16:59:12 -0400 Subject: [PATCH 10/10] ci: build only x86_64/aarch64 wheels and quiet uv cache warning --- .github/workflows/CI.yml | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 6fa4eb6..9ea4673 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -24,6 +24,8 @@ jobs: 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 @@ -38,16 +40,8 @@ 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@v5 - uses: actions/setup-python@v6 @@ -77,12 +71,8 @@ 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@v5 - uses: actions/setup-python@v6