SIGN IN SIGN UP

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