From cf4c605628ebc1c77a84a8854fd6ee88a4c6d6f7 Mon Sep 17 00:00:00 2001 From: Martin Holmberg Date: Wed, 10 Jun 2026 23:23:35 +0200 Subject: [PATCH] Make QRTConnection an async context manager, close Discover transport Closes the asyncio transport-leak ResourceWarnings that surface under Python 3.14 (where the GC-based cleanup path no longer fires before the warning). QRTConnection now supports `async with`, so the transport is closed deterministically on scope exit; disconnect() is idempotent so explicit cleanup remains safe to layer on top. Discover tracks its UDP datagram transport and closes it on StopAsyncIteration, so iterating to the end no longer leaks the socket. Also adds CLAUDE.md (the existing on-disk file used by the team, not yet checked in), updated to reflect current master: Python 3.10+ floor, pyproject.toml release flow with Trusted Publishing and qtm-rt-examples.zip release asset, Ruff linting in CI, FIFO request_queue (per #48), and the new context-manager surface here. Verified end-to-end against QTM 2026.3 Beta on Python 3.14: all seven examples shut down without ResourceWarning when wrapped in `async with connection:` (example wrapping itself is a follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 60 +++++++++++++++++++++++++++++++++++++++++++++ qtm_rt/discovery.py | 9 ++++++- qtm_rt/qrt.py | 11 +++++++-- 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bd3ad87 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project overview + +`qtm_rt` is the Qualisys SDK for Python — a client library that implements Qualisys' RealTime (RT) protocol for talking to QTM (Qualisys Track Manager). Published to PyPI as `qtm-rt`. Targets Python 3.10+ and RT protocol version 1.8+. Little-endian only; default port 22223. + +## Common commands + +```bash +# Setup +python -m venv .venv +source ./.venv/Scripts/activate # Windows bash; on PowerShell: .\.venv\Scripts\Activate.ps1 +pip install -r requirements-dev.txt + +# Tests +pytest test/ +pytest test/qrtconnection_test.py::test_connect_no_loop # single test + +# Lint +python -m ruff check . + +# Build sdist + wheel into dist/ +python -m build + +# Build Sphinx docs into docs/_build/html/ +make -C docs html + +# Enable debug logging at runtime +QTM_LOGGING=debug python your_script.py +``` + +Release flow — bump `version` in `pyproject.toml` (and `docs/conf.py`), open a PR, merge to master, then create a GitHub Release on the tag `vX.Y.Z`. Publishing the release fires `.github/workflows/release.yml`, which lints with Ruff, runs tests, builds the sdist+wheel, twine-checks, uploads to PyPI via Trusted Publishing, and attaches `qtm-rt-examples.zip` (the `examples/` directory plus `Demo.qtm`) to the release. Docs are still a manual step: build with `make -C docs html`, copy `docs/_build/html/*` into the sibling `../qualisys_python_sdk_gh_pages` checkout **preserving the legacy `v102/`, `v103/`, `v212/` directories**, commit/push that branch. + +## Architecture + +Everything is `asyncio`-based and built around `qtm_rt.connect()` → returns a `QRTConnection`. The data flow: + +- **`qrt.py`** — public API surface. `connect()` opens a TCP connection wrapped in `QTMProtocol`, negotiates the RT protocol version (default `"1.25"`), and returns a `QRTConnection` exposing async methods for every RT command (`stream_frames`, `get_current_frame`, `get_parameters`, `take_control`, `start`, `stop`, `load`, `save`, `calibrate`, etc.). The `@validate_response([...])` decorator asserts the server's reply starts with an expected prefix and raises `QRTCommandException` otherwise. `QRTConnection` is an async context manager — prefer `async with await qtm_rt.connect(...) as c:` so the transport is closed deterministically on scope exit. +- **`protocol.py`** — `QTMProtocol` is an `asyncio.Protocol` subclass. Outgoing commands are framed with `RTheader` (` QRTDiscoveryResponse: def protocol_factory(): return QRTDiscoveryProtocol(receiver=self.queue.put_nowait) - _, protocol = await loop.create_datagram_endpoint( + self._transport, protocol = await loop.create_datagram_endpoint( protocol_factory, local_addr=(self.ip_address, 0), allow_broadcast=True, @@ -92,10 +93,16 @@ def protocol_factory(): result = await self.queue.get() if result is None: LOG.debug("Discovery timed out") + self._close() raise StopAsyncIteration LOG.debug(result) call_handle.cancel() return result + def _close(self): + if self._transport is not None: + self._transport.close() + self._transport = None + diff --git a/qtm_rt/qrt.py b/qtm_rt/qrt.py index 2e0f449..ff60a1c 100644 --- a/qtm_rt/qrt.py +++ b/qtm_rt/qrt.py @@ -45,9 +45,16 @@ def __init__(self, protocol: QTMProtocol, timeout): self._protocol = protocol self._timeout = timeout + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + self.disconnect() + def disconnect(self): - """Disconnect from QTM.""" - self._protocol.transport.close() + """Disconnect from QTM. Safe to call multiple times.""" + if self._protocol.transport is not None: + self._protocol.transport.close() def has_transport(self): """ Check if connected to QTM """