feat: Python cffi (API-mode) bindings over the native C core (LEO-320) (#11)
* feat: add Python cffi (API-mode) bindings over the native C core Implements LEO-320: a Pythonic, safe binding over the sw_* C ABI using cffi in API mode, with the C core compiled directly into the extension. - sensorwatch/_native_build.py: ffibuilder compiling src/*.c into sensorwatch._sw_cffi with -DSW_STATIC (no separate DLL; signature drift is a build error). Curated cdef kept in sync with the public header. - sensorwatch/native.py + _native.py: Session/Snapshot context managers, a Reading dataclass, ReadingType enum, SensorwatchError carrying the sw_error_t code + sw_error_string text, a load-time ABI-version guard, and the length-query/copy string helper. - Packaging: switch build backend hatchling -> setuptools + cffi_modules (the wheel is now a compiled extension). setup.py shim, MANIFEST.in ships src/include/tests for source builds, [tool.cibuildwheel] for per-platform Windows wheels. cffi added as a runtime dependency. - CI: the Python job now builds the extension and runs the binding tests; ubuntu validates graceful import + SW_ERR_UNSUPPORTED_PLATFORM. - Publish: build Windows wheels (cibuildwheel) + sdist, then OIDC trusted publish with PEP 740 attestations over all artifacts (coordinates LEO-318). - Docs: README binding section + install/CI notes; C_ABI.md Python notes updated from the ctypes sketch to the shipped cffi binding; SECURITY.md 2.1/2.2/6.3 reflect static-into-extension (no DLL search-order risk) and binary-wheel provenance. Verified end-to-end against live HWiNFO (672 readings; shape matches sensorwatch.hwinfo_shm.read_sensors()). Suite: 54 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address review findings on the cffi binding - include/sensorwatch/sensorwatch.h: drop the stale "no language bindings yet" banner line — a Python cffi binding now ships over the ABI. - native.py Session.__init__: set self._ptr before the open _check, so a failed sw_session_open (e.g. HWiNFO down) leaves a clean, closeable object instead of an AttributeError in close()/__del__ (matches Snapshot's existing pattern). - _native.py: the load-time ABI guard now pins major.minor — pre-1.0 the minor is the breaking-change axis, so the old major-only check was inert during 0.x. - _native.py ReadingType: add _missing_ -> UNKNOWN so an out-of-enum code can never escape as a bare ValueError from Snapshot.__getitem__. - pyproject.toml: soften the cibuildwheel comment (requires-python is not capped at 3.13; the build matrix must be extended as new CPythons are supported). - tests: assert the pinned ABI major.minor and the ReadingType UNKNOWN fallback. Suite: 54 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address external review round on the cffi binding Reviewers (Copilot, codex, claude, kimi) — all non-blocking / LGTM. - _native.py: a load-time ABI mismatch now raises ImportError (not SensorwatchError), so `except ImportError` optional-import fallbacks catch it the same way they catch a missing extension (kimi). - _native.py _query_string: cap the queried length (_MAX_STRING_BYTES) so a faulty/oversized native string length can't drive an unbounded allocation (kimi; aligns with SECURITY.md's untrusted-input posture). - native.py: cache the snapshot-global source and reuse it across readings rather than re-querying per index (N FFI calls + N duplicate strings -> 1) (kimi + internal review). - README + pyproject: footprint wording now lists cffi as a runtime dep (Copilot, codex, claude). - publish.yml + publish-testpypi.yml: use the uv.lock-pinned twine (`uv run python -m twine check`) instead of unpinned `uvx twine` (Copilot, kimi). - .gitignore + MANIFEST.in: ignore/exclude *.o (non-Windows source builds leave sensorwatch/_sw_cffi.o + src/sw_*.o) (claude). - _native_build.py: clarify that *structural* cdef drift is the compile-time guard; implicitly-convertible scalar changes are backstopped by the header's static_asserts + the ABI-version test (claude). - tests: normalize unknown(N)/unknown(255) in the live shape comparison and only assert source on a non-empty snapshot (kimi). The ABI major.minor guard, ReadingType _missing_, and the stale header banner were already addressed in 8c22a8d. Suite: 54 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address second review round (closed-state consistency + atomic close) Round 2 — Copilot re-review + kimi second pass; codex and claude re-reviewed LGTM. - native.py Snapshot: the closed-state guard is now uniform — `source` (even on the cached path), `len()`, and indexing all require the snapshot open and raise after `close()` (Copilot, kimi). Previously a cached source survived close(). - native.py Session/Snapshot `close()`: clear the handle before freeing it (poison-then-free) and document that, like the C ABI, concurrent use of one handle must be synchronized externally (kimi). - tests: the live shape comparison now skips on TOCTOU sensor-set drift between the two independent reads and compares order-independently (kimi). Declined (with rationale): exposing raw unknown type codes — the C ABI intentionally folds them to UNKNOWN (docs/C_ABI.md "Data Model"), so the binding has no raw code to surface; the test's `_norm_type` accommodates that by design. Reusable-workflow dedup of the publish build jobs is deferred (untested refactor). Suite: 54 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address third review round (iter guard + ABI message/doc nits) Round 3 — codex and claude LGTM/mergeable; kimi approve-on-#1. - native.py Snapshot.__iter__: check _require_open() eagerly so iterating a closed snapshot raises even when empty (range(0) would otherwise skip the per-item guard) — completes the uniform closed-state contract (kimi). - _native.py: the ABI-mismatch ImportError now describes the expected version the way the check gates it — major.minor pre-1.0, major-only from 1.0 (Copilot). - native.py module docstring: point at the public sensorwatch.native.SensorwatchError, not the private _native path (Copilot). - README: note that ReadingType follows the C ABI (unrecognized source categories -> UNKNOWN), unlike the pure-Python reader's raw "unknown(<N>)" (kimi #2 doc). - tests: assert iteration is guarded after close. Suite: 54 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Leonard Janke <lcjanke2020@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A
Agent for Leonard Janke committed
c827b2a300d0f87f169922dda6c1390be4e0406e
Parent: 1f83856
Committed by GitHub <noreply@github.com>
on 6/30/2026, 1:46:29 AM