From 65b0c3ee0bee9bca3bcdc0be310c651e1b2cba65 Mon Sep 17 00:00:00 2001 From: Martin Holmberg Date: Thu, 4 Jun 2026 13:50:36 +0200 Subject: [PATCH 1/5] Publish examples as release asset --- .github/workflows/release.yml | 22 ++++++++++++++++++++++ MANIFEST.in | 3 +++ README.md | 6 +++++- examples/asyncio_everything.py | 19 ++++++++++++------- examples/stream_6dof_example.py | 31 ++++++++++++++++++++++++------- 5 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 MANIFEST.in diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7e7138..cfe42da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,3 +55,25 @@ jobs: - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + upload-release-assets: + name: Upload release assets + runs-on: ubuntu-latest + needs: build + permissions: + contents: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Package examples and demo data + run: | + cp qtm_rt/data/Demo.qtm Demo.qtm + zip -r qtm-rt-examples.zip examples Demo.qtm + + - name: Upload assets to GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$RELEASE_TAG" qtm-rt-examples.zip --clobber diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..0df863c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +prune examples +prune docs +prune qtm_rt/data diff --git a/README.md b/README.md index 51a86df..bc2e8a3 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,11 @@ https://qualisys.github.io/qualisys_python_sdk/index.html Examples -------- -See the examples folder. +See the examples folder in the GitHub repository. The examples and demo capture +file are also published as `qtm-rt-examples.zip` on each GitHub release. + +The demo capture file `Demo.qtm` is not included in the PyPI package. Download +the release asset when running examples that use `--qtm-file`. Logging ------- diff --git a/examples/asyncio_everything.py b/examples/asyncio_everything.py index 40ea8c3..9421f8d 100644 --- a/examples/asyncio_everything.py +++ b/examples/asyncio_everything.py @@ -3,7 +3,6 @@ import logging import asyncio import argparse -import pkg_resources import qtm_rt @@ -12,9 +11,6 @@ LOG = logging.getLogger("example") -QTM_FILE = pkg_resources.resource_filename("qtm_rt", "data/Demo.qtm") - - class AsyncEnumerate: """ Simple async enumeration class """ @@ -66,7 +62,7 @@ async def choose_qtm_instance(interface): return instances[choice].host -async def main(interface=None): +async def main(interface=None, qtm_file="Demo.qtm"): """ Main function """ qtm_ip = await choose_qtm_instance(interface) @@ -89,7 +85,7 @@ async def main(interface=None): if result == b"Closing connection": await connection.await_event(qtm_rt.QRTEvent.EventConnectionClosed) - await connection.load(QTM_FILE) + await connection.load(qtm_file) await connection.start(rtfromfile=True) @@ -159,10 +155,19 @@ def parse_args(): default="127.0.0.1", help="IP of interface to search for QTM instances", ) + parser.add_argument( + "--qtm-file", + type=str, + required=False, + default="Demo.qtm", + help="QTM file to load for rtfromfile playback", + ) return parser.parse_args() if __name__ == "__main__": args = parse_args() - asyncio.get_event_loop().run_until_complete(main(interface=args.ip)) + asyncio.get_event_loop().run_until_complete( + main(interface=args.ip, qtm_file=args.qtm_file) + ) diff --git a/examples/stream_6dof_example.py b/examples/stream_6dof_example.py index 69f0d56..db87f9c 100644 --- a/examples/stream_6dof_example.py +++ b/examples/stream_6dof_example.py @@ -3,13 +3,11 @@ """ import asyncio +import argparse import xml.etree.ElementTree as ET -import pkg_resources import qtm_rt -QTM_FILE = pkg_resources.resource_filename("qtm_rt", "data/Demo.qtm") - def create_body_index(xml_string): """ Extract a name to index dictionary from 6dof settings xml """ @@ -21,11 +19,13 @@ def create_body_index(xml_string): return body_to_index + def body_enabled_count(xml_string): xml = ET.fromstring(xml_string) return sum(enabled.text == "true" for enabled in xml.findall("*/Body/Enabled")) -async def main(): + +async def main(qtm_file): # Connect to qtm connection = await qtm_rt.connect("127.0.0.1") @@ -45,7 +45,7 @@ async def main(): await connection.new() else: # Load qtm file - await connection.load(QTM_FILE) + await connection.load(qtm_file) # start rtfromfile await connection.start(rtfromfile=True) @@ -54,7 +54,11 @@ async def main(): xml_string = await connection.get_parameters(parameters=["6d"]) body_index = create_body_index(xml_string) - print("{} of {} 6DoF bodies enabled".format(body_enabled_count(xml_string), len(body_index))) + print( + "{} of {} 6DoF bodies enabled".format( + body_enabled_count(xml_string), len(body_index) + ) + ) wanted_body = "L-frame" @@ -86,6 +90,19 @@ def on_packet(packet): await connection.stream_frames_stop() +def parse_args(): + parser = argparse.ArgumentParser(description="Stream 6DoF data from QTM") + parser.add_argument( + "--qtm-file", + type=str, + required=False, + default="Demo.qtm", + help="QTM file to load for rtfromfile playback", + ) + return parser.parse_args() + + if __name__ == "__main__": + args = parse_args() # Run our asynchronous function until complete - asyncio.get_event_loop().run_until_complete(main()) + asyncio.get_event_loop().run_until_complete(main(args.qtm_file)) From 16ad5bd1ed18f9ac0d6814f00d5f5e04fbd451a0 Mon Sep 17 00:00:00 2001 From: Martin Holmberg Date: Thu, 4 Jun 2026 13:51:47 +0200 Subject: [PATCH 2/5] Add CI and Ruff linting --- .github/workflows/ci.yml | 36 +++++++++++++++++++++++++++++++ .github/workflows/release.yml | 3 +++ examples/qt_example/qt_example.py | 2 +- qtm_rt/__init__.py | 21 ++++++++---------- qtm_rt/control.py | 1 - qtm_rt/discovery.py | 5 ++--- qtm_rt/protocol.py | 3 +-- qtm_rt/qrt.py | 4 ++-- qtm_rt/receiver.py | 8 +++---- requirements-dev.txt | 1 + test/qrtconnection_test.py | 2 +- test/qtmprotocol_test.py | 2 +- 12 files changed, 61 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a5c3014 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + test: + name: Lint and test Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements-dev.txt + + - name: Lint + run: python -m ruff check . + + - name: Run tests + run: python -m pytest test/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfe42da..1b49edf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,9 @@ jobs: python -m pip install --upgrade pip python -m pip install -r requirements-dev.txt + - name: Lint + run: python -m ruff check . + - name: Run tests run: python -m pytest test/ diff --git a/examples/qt_example/qt_example.py b/examples/qt_example/qt_example.py index 834a6d0..70fee81 100644 --- a/examples/qt_example/qt_example.py +++ b/examples/qt_example/qt_example.py @@ -70,7 +70,7 @@ async def _discover_qtm(self, interface): async for qtm_instance in qtm_rt.Discover(interface): info = qtm_instance.info.decode("utf-8").split(",")[0] - if not info in self._found_qtms: + if info not in self._found_qtms: self.discoveredQTM.emit(info, qtm_instance.host) self._found_qtms[info] = True except Exception: diff --git a/qtm_rt/__init__.py b/qtm_rt/__init__.py index e934db9..cf16399 100644 --- a/qtm_rt/__init__.py +++ b/qtm_rt/__init__.py @@ -1,20 +1,17 @@ """ Python SDK for QTM """ import logging -import sys import os -PYTHON3 = sys.version_info.major == 3 - -if PYTHON3: - from .discovery import Discover - from .reboot import reboot - from .qrt import connect, QRTConnection - from .protocol import QRTCommandException - from .control import TakeControl - -from .packet import QRTPacket, QRTEvent -from .receiver import Receiver +from .control import TakeControl as TakeControl +from .discovery import Discover as Discover +from .packet import QRTEvent as QRTEvent +from .packet import QRTPacket as QRTPacket +from .protocol import QRTCommandException as QRTCommandException +from .qrt import QRTConnection as QRTConnection +from .qrt import connect as connect +from .reboot import reboot as reboot +from .receiver import Receiver as Receiver # pylint: disable=C0330 diff --git a/qtm_rt/control.py b/qtm_rt/control.py index 55c98d9..433d463 100644 --- a/qtm_rt/control.py +++ b/qtm_rt/control.py @@ -1,4 +1,3 @@ -import asyncio import logging from .qrt import QRTConnection diff --git a/qtm_rt/discovery.py b/qtm_rt/discovery.py index 936e170..7c9efa4 100644 --- a/qtm_rt/discovery.py +++ b/qtm_rt/discovery.py @@ -75,9 +75,8 @@ async def __anext__(self) -> QRTDiscoveryResponse: loop = asyncio.get_event_loop() if self.first: - protocol_factory = lambda: QRTDiscoveryProtocol( - receiver=self.queue.put_nowait - ) + def protocol_factory(): + return QRTDiscoveryProtocol(receiver=self.queue.put_nowait) _, protocol = await loop.create_datagram_endpoint( protocol_factory, diff --git a/qtm_rt/protocol.py b/qtm_rt/protocol.py index a6f248d..8ac0f0d 100644 --- a/qtm_rt/protocol.py +++ b/qtm_rt/protocol.py @@ -8,8 +8,7 @@ import logging from qtm_rt.packet import QRTPacketType -from qtm_rt.packet import QRTPacket, QRTEvent -from qtm_rt.packet import RTheader, RTEvent, RTCommand +from qtm_rt.packet import RTheader, RTCommand from qtm_rt.receiver import Receiver # pylint: disable=C0330 diff --git a/qtm_rt/qrt.py b/qtm_rt/qrt.py index ff4b014..2e0f449 100644 --- a/qtm_rt/qrt.py +++ b/qtm_rt/qrt.py @@ -104,7 +104,7 @@ async def get_parameters(self, parameters=None): parameters = ["all"] else: for parameter in parameters: - if not parameter in [ + if parameter not in [ "all", "general", "3d", @@ -374,7 +374,7 @@ async def connect( def _validate_components(components): for component in components: - if not component.lower() in [ + if component.lower() not in [ "2d", "2dlin", "3d", diff --git a/qtm_rt/receiver.py b/qtm_rt/receiver.py index 82b6fd3..cdf2e5b 100644 --- a/qtm_rt/receiver.py +++ b/qtm_rt/receiver.py @@ -2,7 +2,7 @@ from qtm_rt.packet import QRTPacketType from qtm_rt.packet import QRTPacket, QRTEvent -from qtm_rt.packet import RTheader, RTEvent, RTCommand +from qtm_rt.packet import RTheader, RTEvent LOG = logging.getLogger("qtm_rt") @@ -17,16 +17,16 @@ def data_received(self, data): self._received_data += data h_size = RTheader.size data = self._received_data - data_len = len(data); + data_len = len(data) while data_len >= h_size: size, type_ = RTheader.unpack_from(data, 0) if data_len >= size: self._parse_received(data[h_size:size], type_) data = data[size:] - data_len = len(data); + data_len = len(data) else: - break; + break self._received_data = data diff --git a/requirements-dev.txt b/requirements-dev.txt index 8555572..6bdf805 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,5 +2,6 @@ build==1.2.1 pytest-asyncio==0.23.7 pytest-mock==3.14.0 pytest==8.2.2 +ruff==0.15.15 sphinx==7.3.7 twine==6.2.0 diff --git a/test/qrtconnection_test.py b/test/qrtconnection_test.py index d1227bd..b132cda 100644 --- a/test/qrtconnection_test.py +++ b/test/qrtconnection_test.py @@ -316,7 +316,7 @@ async def xml(*_): a_qrt._protocol.receive_response.side_effect = xml with pytest.raises(QRTCommandException): - response = await a_qrt.calibrate() + await a_qrt.calibrate() a_qrt._protocol.send_command.assert_called_once_with("calibrate") diff --git a/test/qtmprotocol_test.py b/test/qtmprotocol_test.py index ca705cc..8ba60af 100644 --- a/test/qtmprotocol_test.py +++ b/test/qtmprotocol_test.py @@ -8,7 +8,7 @@ import pytest from qtm_rt.protocol import QTMProtocol, QRTCommandException -from qtm_rt.packet import QRTEvent, RTEvent +from qtm_rt.packet import QRTEvent # pylint: disable=W0621, C0111, W0212 From 16eada9cd9fda98c1ea360c119b88c6f9f815faa Mon Sep 17 00:00:00 2001 From: Martin Holmberg Date: Thu, 4 Jun 2026 14:22:34 +0200 Subject: [PATCH 3/5] Make Ruff rule set explicit in pyproject.toml Pin Ruff's effective configuration so a future Ruff upgrade can't silently change what CI enforces: - line-length = 88 (current default) - target-version = py310 (matches requires-python) - lint.select = current default set (E4, E7, E9, F) No behaviour change: ruff check . still passes. Comment documents how to broaden (e.g. I for import sorting, UP for pyupgrade). Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b2b7291..8a0badf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,3 +35,13 @@ Source = "https://github.com/qualisys/qualisys_python_sdk" packages = ["qtm_rt"] zip-safe = true include-package-data = false + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +# Ruff's default rule set (pyflakes + a subset of pycodestyle), pinned +# explicitly so a Ruff version bump can't silently change what CI enforces. +# Broaden deliberately, e.g. add "I" (import sorting) or "UP" (pyupgrade). +select = ["E4", "E7", "E9", "F"] From 24a82619221105ff3e62002034efddb0d1931abe Mon Sep 17 00:00:00 2001 From: Martin Holmberg Date: Thu, 4 Jun 2026 14:27:54 +0200 Subject: [PATCH 4/5] Bump GitHub Actions to Node 24 runtimes The Node 20 action runtime is deprecated: GitHub forces Node 24 from 2026-06-16 and removes Node 20 on 2026-09-16. Update to the latest majors, which run on Node 24: - actions/checkout v4 -> v6 - actions/setup-python v5 -> v6 - actions/upload-artifact v4 -> v7 (release.yml) - actions/download-artifact v4 -> v8 (release.yml) upload-artifact v7 and download-artifact v8 co-evolved and remain compatible; the default zip round-trip used here is unchanged. pypa/gh-action-pypi-publish is a container action and is unaffected. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5c3014..ade634e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b49edf..c42e803 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Package examples and demo data run: | From efc898ab569527ad6a0a39e107e6955a918cedfa Mon Sep 17 00:00:00 2001 From: Martin Holmberg Date: Thu, 4 Jun 2026 14:49:18 +0200 Subject: [PATCH 5/5] CI: run push builds only on master to avoid duplicate PR runs push.branches "**" matched every branch, so each commit on a PR branch triggered both a push run and a pull_request run for the same SHA. Scope push to master; pull_request already covers all PRs (including forks, built against the merge result). Branches without an open PR no longer build until a PR is opened. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ade634e..4c08341 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: ["**"] + branches: [master] pull_request: jobs: