From 1885e1ce7482981ea5adeb2130027c7cbd48eb8c Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 03:55:02 +1000 Subject: [PATCH 1/2] =?UTF-8?q?feat(concurrency):=20free-threaded=20CPytho?= =?UTF-8?q?n=20support=20=E2=80=94=20memory-ordering=20fixes,=20gil=5Fused?= =?UTF-8?q?=3Dfalse,=20CI=20lane=20(LAB-511)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make cachekit-py provably race-free under free-threaded CPython and gate regressions in CI: - decorators/session.py: the lock-free fast path and in-lock double-check now gate on every published field (_session_start_ms included). Assignment order only guaranteed visibility order under the GIL; a GIL-free reader observing pid+id before start_ms hit the 'should never happen' RuntimeError and silently dropped session headers (the LAB-506 telemetry loss, resurfacing GIL-free). Regression tests pin the mid-publish state deterministically and fail pre-fix. - reliability/metrics_collection.py: AsyncMetricsCollector.flush polled Queue.empty(), which flips at dequeue — before processing finishes. Waits on unfinished_tasks now. Was a routine flake on the free-threaded lane. - rust/src/lib.rs: #[pymodule(gil_used = false)] — the PyO3 0.28+ default made explicit, justified by the LAB-511 audit (AtomicU64 nonce, Mutex metrics, &self-only pyclasses, Send+Sync compiler-enforced). - CI: new test-freethreaded job runs unit+critical on 3.14t, asserts the GIL stays disabled after importing cachekit (hiredis excluded — no Py_mod_gil declaration; redis-py falls back to pure-Python parser). - pyproject: dependency-groups split into test (free-threading-compatible core toolchain) + dev (includes test; adds the extras without free-threaded wheels: orjson, numpy, pandas, pyarrow). - tests: importorskip guards so the core suites honestly run without the [data]/[json] extras; full audit table in docs/free-threading.md. Free-threaded wheels/classifiers explicitly deferred: orjson (build rejects free-threaded), hiredis (no Py_mod_gil), numpy/pandas/pyarrow coverage. --- .github/workflows/ci.yml | 48 ++++++++- README.md | 8 ++ docs/README.md | 1 + docs/free-threading.md | 102 ++++++++++++++++++ pyproject.toml | 30 ++++-- rust/src/lib.rs | 10 +- src/cachekit/decorators/session.py | 24 +++-- .../reliability/metrics_collection.py | 14 ++- .../test_cache_serializer_compression.py | 6 +- .../test_cache_serializer_patterns.py | 8 +- tests/critical/test_encryption_integration.py | 2 +- .../critical/test_production_data_patterns.py | 5 +- tests/unit/test_arrow_serializer.py | 11 +- ...auto_serializer_mutation_and_corruption.py | 7 +- tests/unit/test_auto_serializer_new_types.py | 4 + .../test_auto_serializer_numpy_integrity.py | 5 +- tests/unit/test_docs_conftest_no_key_leak.py | 5 + .../test_encryption_security_invariants.py | 7 +- tests/unit/test_free_threading.py | 80 ++++++++++++++ tests/unit/test_key_generator_blake2b.py | 5 +- tests/unit/test_mmap_read_path.py | 3 + tests/unit/test_orjson_serializer.py | 8 +- tests/unit/test_saas_observability.py | 75 +++++++++++++ tests/unit/test_serializer_integrity.py | 11 +- tests/unit/test_serializer_lazy_loading.py | 14 ++- tests/unit/test_serializer_protocol.py | 12 ++- tests/unit/test_xxhash_integrity.py | 11 +- uv.lock | 38 +++++++ 28 files changed, 497 insertions(+), 57 deletions(-) create mode 100644 docs/free-threading.md create mode 100644 tests/unit/test_free_threading.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 927d2ec..46d3a6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,6 +172,49 @@ jobs: flags: ${{ github.event_name == 'push' && 'full' || 'critical' }}-python-${{ matrix.python-version }} fail_ci_if_error: false + # LAB-511: free-threaded CPython safety net. Runs the core suites on a + # 3.14 free-threaded build and fails if the GIL gets re-enabled, so code + # whose correctness silently depended on the GIL's memory ordering cannot + # regress unnoticed. Optional native deps without free-threaded wheels + # (orjson, numpy, pandas, pyarrow) are dev-group-only and stay out + # (--group test); hiredis is skipped because it does not declare + # free-threaded support — importing it re-enables the GIL — and redis-py + # transparently falls back to its pure-Python parser. + test-freethreaded: + name: Tests (Python 3.14t, GIL disabled) + runs-on: cachekit + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Start Redis (no persistence) + run: | + docker run -d --name redis -p 6379:6379 \ + redis:7-alpine redis-server --save "" --appendonly no + until docker exec redis redis-cli ping | grep -q PONG; do sleep 1; done + + - name: Install dependencies (test group only) + run: uv sync --python 3.14t --no-default-groups --group test --no-install-package hiredis + + - name: Verify the GIL is actually disabled + run: | + uv run --no-sync python -c " + import sys, sysconfig + assert sysconfig.get_config_var('Py_GIL_DISABLED') == 1, 'not a free-threaded build' + import cachekit + import cachekit._rust_serializer + import redis + assert not sys._is_gil_enabled(), 'an import re-enabled the GIL' + print('free-threaded build, GIL still disabled after imports') + " + + - name: Run unit + critical suites (GIL-free) + env: + REDIS_URL: redis://localhost:6379 + run: | + uv run --no-sync pytest tests/unit/ -m "not slow" -n auto + uv run --no-sync pytest tests/critical/ -m "not slow" + # Version sync + doc tests (push to main only) post-merge: name: Post-Merge Checks @@ -209,13 +252,14 @@ jobs: ci-success: name: CI Success runs-on: ubuntu-latest - needs: [quick-check, test] + needs: [quick-check, test, test-freethreaded] if: always() steps: - name: Check all jobs succeeded run: | if [[ "${{ needs.quick-check.result }}" != "success" ]] || \ - [[ "${{ needs.test.result }}" != "success" ]]; then + [[ "${{ needs.test.result }}" != "success" ]] || \ + [[ "${{ needs.test-freethreaded.result }}" != "success" ]]; then echo "One or more jobs failed" exit 1 fi diff --git a/README.md b/README.md index 269b734..4059b5e 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,14 @@ exposition setup.
Thread Safety Details +**Free-threaded CPython (3.13t/3.14t):** the core suites run green on +free-threaded 3.14 with the GIL verified disabled (CI job +`test-freethreaded`), and the Rust extension declares free-threaded safety +(`gil_used = false`). Free-threaded wheels are **not yet published** and +free-threaded builds are not officially supported — blocked on upstream +wheels (orjson, hiredis; numpy/pandas/pyarrow for `[data]`). Details and the +full concurrency audit: [docs/free-threading.md](docs/free-threading.md). + **Per-Function Statistics:** - Statistics tracked per function identity (`module.qualname`), shared across all calls and across re-decorations of the same function - Thread-safe via RLock (all methods safe for concurrent access) diff --git a/docs/README.md b/docs/README.md index fd99e40..b7f6476 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,6 +65,7 @@ Choose how data is stored: | [Performance](performance.md) | Benchmarks and optimization | | [Comparison](comparison.md) | vs. lru\_cache, aiocache, cachetools | | [Error Codes](error-codes.md) | Error reference | +| [Free-Threaded CPython](free-threading.md) | Support status, concurrency audit, CI safety net | --- diff --git a/docs/free-threading.md b/docs/free-threading.md new file mode 100644 index 0000000..6f82b89 --- /dev/null +++ b/docs/free-threading.md @@ -0,0 +1,102 @@ +# Free-Threaded CPython (3.13t / 3.14t) + +Status as of LAB-511 (2026-08): **tested, not yet declared**. + +- The core test suites (`tests/unit/`, `tests/critical/`) run green on + free-threaded CPython 3.14 with the GIL verified disabled, gated by the + `test-freethreaded` CI job on every PR and push. +- The Rust extension declares free-threaded safety + (`#[pymodule(gil_used = false)]`), so importing `cachekit._rust_serializer` + does not force the GIL back on. +- **No free-threaded (`cp31Nt`) wheels are published yet**, and installing + cachekit on a free-threaded interpreter is not officially supported. See + [Deferred: declared support](#deferred-declared-support--free-threaded-wheels). + +## What "works only under the GIL" used to mean here + +Lock-free fast paths written under the GIL inherit its implicit guarantees: +one bytecode interleaving at a time and, effectively, sequentially consistent +publication of writes. Free-threaded CPython removes both. Its memory model +does not specify cross-variable store visibility order for plain reads, so a +reader may observe a *later* store while an *earlier* one is not yet visible. + +Concrete instance (the defect that motivated LAB-511): +`decorators/session.py::_ensure_session_initialized` published three module +globals and relied on assignment order (`_session_start_ms`, then +`_session_id`, then `_session_pid`) to make the lock-free fast path safe. A +GIL-free reader observing `pid`+`id` but not yet `start_ms` sailed past the +fast path into `get_session_start_ms()`'s "should never happen" +`RuntimeError` — which `backends/cachekitio/backend.py` catches and converts +into *silently dropped session headers*, the exact telemetry loss LAB-506 +eliminated. The fix gates the fast path (and the in-lock double-check) on +**every** published field; observing a partial publish now falls through to +the lock and waits for the in-flight initializer. + +Rule of thumb applied throughout the audit: + +- **Single-assignment publication** of a fully-constructed object through one + reference (e.g. a double-checked module-global singleton) is acceptable. +- **Multi-field publication** that readers expect to be mutually consistent + must be lock-protected or gated on every field — assignment order proves + nothing without the GIL. + +## Concurrency audit (LAB-511) + +Every lock-free fast path and shared mutable module/instance state named by +the ticket, plus what the free-threaded CI lane surfaced: + +| Site | Mechanism | Verdict | +|:-----|:----------|:--------| +| `decorators/session.py` `_ensure_session_initialized` | Lock-free fast path over three module globals | **Fixed** — fast path and double-check gate on all three fields; regression tests in `tests/unit/test_saas_observability.py::TestMidPublishMemoryOrdering` and `tests/unit/test_free_threading.py` | +| `reliability/metrics_collection.py` `AsyncMetricsCollector.flush` | Polled `Queue.empty()` | **Fixed** — `empty()` flips when the worker *dequeues*, not when it finishes processing; flush now waits on `unfinished_tasks` (zeroed by `task_done()` after processing). Was a routine flake on the free-threaded lane, invisible under the GIL's coarse scheduling | +| `decorators/wrapper.py` `_FunctionStats` | `RLock` around every counter mutation and `get_info` | Safe. `l1_enabled` is a plain attribute re-set on re-decoration (under the registry lock) and read without the stats lock; a stale read yields a conservative rate-limit classification header, never corruption | +| `decorators/wrapper.py` `_function_stats_registry` | Module-level `Lock` around all access | Safe | +| `os.register_at_fork` handlers (session + stats registry) | Run in the child while single-threaded; replace locks wholesale | Safe — single-threaded by construction at execution time | +| `backends/cachekitio/session.py` header cache | `threading.local` | Safe — per-thread state; its only cross-thread hazard was the session-identity publication above | +| `decorators/stats_context.py` | `contextvars.ContextVar` | Safe by construction | +| `decorators/wrapper.py` L1/SWR + L2/SWR single-flight (`_l1_swr_*`, `_l2_swr_*`) | `BoundedSemaphore` slots + in-flight `set` + PID-owner swap | Safe. The check-then-add on the in-flight set was already documented as benign (worst case one duplicate refresh, absorbed by last-write-wins / the backend lease); builtin `set`/`dict` single ops are atomic under free-threading's per-object locking. The fork-detection wholesale swap races are the same documented-benign shape | +| `decorators/wrapper.py` `_cached_keys` | Builtin `set`, snapshot-copied before iteration in invalidation | Safe — single ops atomic; a copy racing an add can only miss a concurrently-written key, which invalidate-all semantics tolerate | +| `object_cache.py` `ObjectCache` | `RLock` on every public method | Safe | +| `reliability/metrics_collection.py` `get_async_metrics_collector` | Double-checked module-global singleton | Safe — single-assignment publication of a fully-constructed object; worst case a benign duplicate worker-restart check | +| Rust extension (`rust/src/`, cachekit-core 0.5.0) | `#[pymodule(gil_used = false)]`; every `#[pyclass]` exposes only `&self` methods; nonce counter is `AtomicU64`, metrics behind `Mutex`; PyO3 enforces `Send + Sync` on pyclasses at compile time | Safe — declared free-threading-ready. (The wasm32 `Cell` nonce variant is single-threaded by target.) | + +## The CI safety net + +`.github/workflows/ci.yml` job `test-freethreaded`: + +1. Installs with `uv sync --python 3.14t --no-default-groups --group test + --no-install-package hiredis`. The `test` dependency group is the core + test toolchain; the extras that lack free-threaded wheels (orjson, numpy, + pandas, pyarrow) live only in the `dev` group and the `[data]`/`[json]` + extras, and their tests skip via `pytest.importorskip`. +2. Asserts the interpreter is a free-threaded build **and** that + `sys._is_gil_enabled()` is still `False` after importing `cachekit`, + `cachekit._rust_serializer`, and `redis` — a dependency that fails to + declare free-threaded support re-enables the GIL for the whole process at + import time, which would silently turn the lane back into a GIL run. + `tests/unit/test_free_threading.py` re-asserts this from inside the suite. +3. Runs `tests/unit/` and `tests/critical/`. + +hiredis is excluded because it does not declare free-threaded support (no +`Py_mod_gil` slot); redis-py transparently falls back to its pure-Python +parser. On a GIL build nothing changes — hiredis remains the default parser. + +## Deferred: declared support + free-threaded wheels + +Publishing `cp314t` wheels and declaring official free-threaded support is +**explicitly deferred** (per the LAB-511 acceptance criteria) until the +dependency chain allows it. Blocking as of 2026-08: + +- **orjson** — no free-threaded wheels through 3.12.0, and its build script + rejects free-threaded interpreters ("does not support free-threaded + Python"). Optional `[json]` extra, but a support declaration that breaks + the moment a user adds `cachekit[json]` is not a declaration worth making. +- **hiredis** — no `Py_mod_gil` declaration; importing it re-enables the GIL. + Pulled in unconditionally via the required `redis[hiredis]` dependency. +- **numpy / pandas / pyarrow** — the `[data]` extra; free-threaded wheel + coverage across all three is not yet complete enough to declare. + +When those clear: add `-i python3.14t` targets to the `build-wheels` matrix in +`.github/workflows/release-please.yml`, revisit `redis[hiredis]` (marker or +documented degradation), and update this page plus the README support +statement. Track upstream — do not fork or vendor (LAB-511 non-goal). diff --git a/pyproject.toml b/pyproject.toml index 3860e79..e09eaad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -200,8 +200,12 @@ exclude_lines = [ ] [dependency-groups] -dev = [ - # Testing +# Core test toolchain — everything tests/unit + tests/critical need on ANY +# interpreter, including free-threaded CPython: the free-threaded CI lane +# installs ONLY this group (LAB-511). A dep may live here only if it ships +# free-threaded wheels or builds cleanly from source on 3.14t; deps that +# don't (orjson, numpy, pandas, pyarrow) stay in dev below. +test = [ "fakeredis>=2.21.0", "pytest>=7.0.0", "pytest-asyncio>=0.21.0", @@ -210,6 +214,19 @@ dev = [ "pytest-markdown-docs>=0.6.0", "pytest-redis>=3.0.0", "pymemcache>=4.0.0", + # Utilities + "faker>=20.0.0", + "httpx>=0.28.1", + "hypothesis>=6.0.0", + "requests>=2.33.0; python_version >= '3.10'", + "psutil>=5.9.0", + "python-dotenv>=1.0.0", + "pyyaml>=6.0.3", + "pytest-xdist>=3.8.0", + "time-machine>=2.19.0", +] +dev = [ + { include-group = "test" }, # Competitive comparison suite (tests/competitive/ benchmarks cachekit vs these) "cachetools>=5.3.0", "aiocache>=0.12.0", @@ -218,14 +235,7 @@ dev = [ "ruff>=0.6.0", # Utilities "bashlex>=0.18", - "faker>=20.0.0", - "httpx>=0.28.1", - "hypothesis>=6.0.0", "pip-audit>=2.7.0", - "requests>=2.33.0; python_version >= '3.10'", - "psutil>=5.9.0", - "python-dotenv>=1.0.0", - "pyyaml>=6.0.3", # Data science support (for testing AutoSerializer with numpy/pandas) "numpy>=2.0.2", "pandas>=1.3.0", @@ -233,8 +243,6 @@ dev = [ # OrjsonSerializer support — now the [json] optional extra; kept here so the # orjson tests, doctests, and markdown-docs still resolve it in dev/CI. "orjson>=3.9.0", - "pytest-xdist>=3.8.0", - "time-machine>=2.19.0", ] # Linux CI only - Atheris requires libFuzzer (not available on macOS without building LLVM) fuzz = [ diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 9c451d0..8afa7f2 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -21,8 +21,16 @@ pub mod python_bindings; use pyo3::prelude::*; /// Python module definition - exports raw byte storage and encryption +/// +/// `gil_used = false` (the PyO3 0.28+ default, made explicit): declares the +/// module thread-safe under free-threaded CPython so importing it does not +/// force the GIL back on. Verified by the LAB-511 audit: every `#[pyclass]` +/// exposes only `&self` methods, and shared state in cachekit-core is +/// `AtomicU64` (nonce counter) or `Mutex` (metrics) — no interior mutability +/// the GIL was papering over. PyO3 enforces `Send + Sync` on every pyclass at +/// compile time. #[cfg(feature = "python")] -#[pymodule] +#[pymodule(gil_used = false)] fn _rust_serializer(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { // Add byte storage class m.add_class::()?; diff --git a/src/cachekit/decorators/session.py b/src/cachekit/decorators/session.py index 39f161f..713cf72 100644 --- a/src/cachekit/decorators/session.py +++ b/src/cachekit/decorators/session.py @@ -56,19 +56,27 @@ def _ensure_session_initialized() -> None: current_pid = os.getpid() - # Fast path: session already initialized for this process - if _session_pid == current_pid and _session_id is not None: + # Fast path: session already initialized for this process. Gates on EVERY + # published field, not just pid+id: assignment order below only guarantees + # visibility order under the GIL. On free-threaded CPython with a weak + # memory model (e.g. ARM64) a lock-free reader may observe the stores out + # of order — pid+id set while start_ms still reads None — and a pid+id + # gate would admit it straight into get_session_start_ms()'s RuntimeError, + # silently dropping the session headers upstream. Observing a partial + # publish here just means falling through to the lock, where the + # in-flight initializer completes before the double-check re-runs. + if _session_pid == current_pid and _session_id is not None and _session_start_ms is not None: return with _session_lock: - # Double-check inside lock (another thread might have initialized) - if _session_pid == current_pid and _session_id is not None: + # Double-check inside lock (another thread might have initialized). + # Same full-field gate as the fast path: entering the lock does not + # retroactively order stores a GIL-less writer made before we blocked. + if _session_pid == current_pid and _session_id is not None and _session_start_ms is not None: return - # _session_pid is assigned LAST: the fast path above reads without the - # lock and admits readers once pid+id are set, so all other fields must - # already be populated by then (a reader admitted between id and - # start_ms assignments would find start_ms still None). + # _session_pid is assigned LAST as defense in depth for GIL builds, + # but the readers above no longer rely on assignment order. _session_start_ms = int(time.time() * 1000) _session_id = str(uuid.uuid4()) _session_pid = current_pid diff --git a/src/cachekit/reliability/metrics_collection.py b/src/cachekit/reliability/metrics_collection.py index e47cbf8..1783d2b 100644 --- a/src/cachekit/reliability/metrics_collection.py +++ b/src/cachekit/reliability/metrics_collection.py @@ -288,11 +288,19 @@ def clear(self): self._stats["dropped_count"] = 0 def flush(self, timeout: float = 2.0): - """Flush all pending metrics with timeout.""" + """Flush all pending metrics with timeout. + + Waits for the worker to finish PROCESSING, not merely dequeue: + Queue.empty() flips the moment the worker get()s the last item — + before _process_metric runs — so polling empty() can return with the + final metric still mid-flight. Unobservable in practice under the + GIL's coarse scheduling, routine under free-threaded CPython + (LAB-511). unfinished_tasks only reaches zero at task_done(), which + the worker calls after processing and the stats update. + """ if self._worker_thread and self._worker_thread.is_alive(): - # Wait for queue to be processed start_time = time.time() - while not self._metric_queue.empty() and (time.time() - start_time) < timeout: + while self._metric_queue.unfinished_tasks and (time.time() - start_time) < timeout: time.sleep(0.01) def shutdown(self, timeout: float = 2.0): diff --git a/tests/critical/test_cache_serializer_compression.py b/tests/critical/test_cache_serializer_compression.py index 88a2e23..b6f13b6 100644 --- a/tests/critical/test_cache_serializer_compression.py +++ b/tests/critical/test_cache_serializer_compression.py @@ -1,10 +1,12 @@ """Test CacheSerializer compression functionality""" -import numpy as np import pytest +# Requires the [data] extra — absent e.g. in the free-threaded CI lane (LAB-511). +np = pytest.importorskip("numpy") + # Import from compatibility wrapper -from tests.critical.cache_serializer_compat import CACHE_SERIALIZER_AVAILABLE, CacheSerializer +from tests.critical.cache_serializer_compat import CACHE_SERIALIZER_AVAILABLE, CacheSerializer # noqa: E402 @pytest.mark.skipif(not CACHE_SERIALIZER_AVAILABLE, reason="Cache serializer not available") diff --git a/tests/critical/test_cache_serializer_patterns.py b/tests/critical/test_cache_serializer_patterns.py index f9fa3d0..ff83b29 100644 --- a/tests/critical/test_cache_serializer_patterns.py +++ b/tests/critical/test_cache_serializer_patterns.py @@ -1,11 +1,13 @@ """Test CacheSerializer pattern detection and serialization""" -import numpy as np -import pandas as pd import pytest +# Requires the [data] extra — absent e.g. in the free-threaded CI lane (LAB-511). +np = pytest.importorskip("numpy") +pd = pytest.importorskip("pandas") + # Import from compatibility wrapper -from tests.critical.cache_serializer_compat import CACHE_SERIALIZER_AVAILABLE, CacheSerializer +from tests.critical.cache_serializer_compat import CACHE_SERIALIZER_AVAILABLE, CacheSerializer # noqa: E402 @pytest.mark.skipif(not CACHE_SERIALIZER_AVAILABLE, reason="Cache serializer not available") diff --git a/tests/critical/test_encryption_integration.py b/tests/critical/test_encryption_integration.py index 4a5d2b8..d6f650c 100644 --- a/tests/critical/test_encryption_integration.py +++ b/tests/critical/test_encryption_integration.py @@ -354,7 +354,7 @@ def test_encrypted_arrow_dataframe_uses_arrow_not_messagepack(self): 2. A DataFrame survives encrypt -> store-in-Redis -> retrieve -> decrypt unchanged. 3. The raw bytes in Redis are ciphertext (no plaintext column values). """ - import pandas as pd + pd = pytest.importorskip("pandas") # requires the [data] extra (LAB-511) from cachekit.cache_handler import CacheSerializationHandler from cachekit.config.nested import EncryptionConfig, L1CacheConfig diff --git a/tests/critical/test_production_data_patterns.py b/tests/critical/test_production_data_patterns.py index a045906..6ef737d 100644 --- a/tests/critical/test_production_data_patterns.py +++ b/tests/critical/test_production_data_patterns.py @@ -30,10 +30,11 @@ except ImportError: HAS_NUMPY = False -import pandas as pd +# Requires the [data] extra — absent e.g. in the free-threaded CI lane (LAB-511). +pd = pytest.importorskip("pandas") # Import from compatibility wrapper -from tests.critical.cache_serializer_compat import CACHE_SERIALIZER_AVAILABLE, CacheSerializer +from tests.critical.cache_serializer_compat import CACHE_SERIALIZER_AVAILABLE, CacheSerializer # noqa: E402 class ProductionDataGenerator: diff --git a/tests/unit/test_arrow_serializer.py b/tests/unit/test_arrow_serializer.py index 972df67..be67a07 100644 --- a/tests/unit/test_arrow_serializer.py +++ b/tests/unit/test_arrow_serializer.py @@ -5,12 +5,15 @@ from __future__ import annotations -import pandas as pd -import pyarrow as pa import pytest -from cachekit.serializers.arrow_serializer import ArrowSerializer -from cachekit.serializers.base import SerializationError, SerializationFormat, SerializationMetadata +# ArrowSerializer requires the [data] extra — absent e.g. in the free-threaded +# CI lane until pandas/pyarrow ship free-threaded wheels (LAB-511). +pd = pytest.importorskip("pandas") +pa = pytest.importorskip("pyarrow") + +from cachekit.serializers.arrow_serializer import ArrowSerializer # noqa: E402 +from cachekit.serializers.base import SerializationError, SerializationFormat, SerializationMetadata # noqa: E402 class TestArrowSerializerBasics: diff --git a/tests/unit/test_auto_serializer_mutation_and_corruption.py b/tests/unit/test_auto_serializer_mutation_and_corruption.py index f30b616..8eb239e 100644 --- a/tests/unit/test_auto_serializer_mutation_and_corruption.py +++ b/tests/unit/test_auto_serializer_mutation_and_corruption.py @@ -16,13 +16,16 @@ from __future__ import annotations -import numpy as np -import pandas as pd import pytest from cachekit.serializers import AutoSerializer from cachekit.serializers.base import SerializationError +# Requires the [data] extra — absent e.g. in the free-threaded CI lane until +# numpy/pandas ship free-threaded wheels (LAB-511). +np = pytest.importorskip("numpy") +pd = pytest.importorskip("pandas") + def _no_arrow() -> AutoSerializer: """An AutoSerializer forced onto the columnar msgpack DataFrame path (pyarrow absent).""" diff --git a/tests/unit/test_auto_serializer_new_types.py b/tests/unit/test_auto_serializer_new_types.py index b05187d..6cdc070 100644 --- a/tests/unit/test_auto_serializer_new_types.py +++ b/tests/unit/test_auto_serializer_new_types.py @@ -375,6 +375,10 @@ def test_corruption_in_nested_structure(self): def test_partial_truncation_detection(self): """Test that truncated data raises appropriate errors.""" + # Truncated msgpack can misparse into the numpy-envelope branch, whose + # error classification differs without the [data] extra installed + # (RuntimeError from the numpy guard instead of SerializationError). + pytest.importorskip("numpy") serializer = AutoSerializer() valid_uuid = UUID("12345678-1234-5678-1234-567812345678") diff --git a/tests/unit/test_auto_serializer_numpy_integrity.py b/tests/unit/test_auto_serializer_numpy_integrity.py index b928859..4644a84 100644 --- a/tests/unit/test_auto_serializer_numpy_integrity.py +++ b/tests/unit/test_auto_serializer_numpy_integrity.py @@ -14,13 +14,16 @@ from __future__ import annotations -import numpy as np import pytest import xxhash from cachekit.serializers import AutoSerializer from cachekit.serializers.base import SerializationError +# Requires the [data] extra — absent e.g. in the free-threaded CI lane until +# numpy ships free-threaded wheels usable here (LAB-511). +np = pytest.importorskip("numpy") + @pytest.mark.unit class TestAutoSerializerNumpyIntegrity: diff --git a/tests/unit/test_docs_conftest_no_key_leak.py b/tests/unit/test_docs_conftest_no_key_leak.py index f514333..b20a0b7 100644 --- a/tests/unit/test_docs_conftest_no_key_leak.py +++ b/tests/unit/test_docs_conftest_no_key_leak.py @@ -19,6 +19,11 @@ import pytest +# docs/conftest.py imports numpy/pandas at module level (its fences use them) — +# absent e.g. in the free-threaded CI lane (LAB-511). +pytest.importorskip("numpy") +pytest.importorskip("pandas") + DOCS_CONFTEST = Path(__file__).resolve().parents[2] / "docs" / "conftest.py" diff --git a/tests/unit/test_encryption_security_invariants.py b/tests/unit/test_encryption_security_invariants.py index 9002eb9..5219f11 100644 --- a/tests/unit/test_encryption_security_invariants.py +++ b/tests/unit/test_encryption_security_invariants.py @@ -15,10 +15,15 @@ from cachekit.config.validation import ConfigurationError from cachekit.serializers.base import SerializationError from cachekit.serializers.encryption_wrapper import DecryptionAuthenticationError, EncryptionError, EncryptionWrapper -from cachekit.serializers.orjson_serializer import OrjsonSerializer from cachekit.serializers.standard_serializer import StandardSerializer from cachekit.serializers.wrapper import SerializationWrapper +# OrjsonSerializer requires the [json] extra — absent e.g. in the free-threaded +# CI lane until orjson ships free-threaded wheels (LAB-511). +pytest.importorskip("orjson") + +from cachekit.serializers.orjson_serializer import OrjsonSerializer # noqa: E402 + @pytest.fixture(autouse=True) def setup_di_for_redis_isolation(): diff --git a/tests/unit/test_free_threading.py b/tests/unit/test_free_threading.py new file mode 100644 index 0000000..e2fe8ba --- /dev/null +++ b/tests/unit/test_free_threading.py @@ -0,0 +1,80 @@ +"""Free-threaded CPython guarantees (LAB-511). + +The CI lane `test-freethreaded` runs the core suites on a free-threaded 3.14 +build. These tests make the lane's central claim self-verifying from inside +the suite: on a free-threaded interpreter, importing cachekit (including the +Rust extension) must not re-enable the GIL. On GIL builds they skip — the +claim is about free-threaded builds only, and the session-identity hammer +below runs everywhere as a plain thread-safety regression net. +""" + +from __future__ import annotations + +import sys +import sysconfig +import threading + +import pytest + +_FREE_THREADED_BUILD = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + + +@pytest.mark.skipif(not _FREE_THREADED_BUILD, reason="requires a free-threaded CPython build") +def test_gil_stays_disabled_after_importing_cachekit(): + """cachekit (incl. the PyO3 extension, gil_used=false) must not force the GIL back on. + + A dependency without a free-threaded declaration re-enables the GIL for + the whole process at import time, silently turning the free-threaded lane + back into a GIL run — this asserts the lane actually tests what it claims. + """ + import cachekit # noqa: F401 + import cachekit._rust_serializer # noqa: F401 + + assert sys._is_gil_enabled() is False + + +def test_session_init_hammer_no_partial_publish_observed(): + """Many threads racing first-touch session init never observe a partial identity. + + On GIL builds this is a smoke test; on the free-threaded lane it races for + real. get_session_start_ms() raising RuntimeError here is exactly the + mid-publish observation the LAB-511 guard in _ensure_session_initialized + exists to prevent. + """ + from cachekit.decorators import session as session_module + + saved = ( + session_module._session_pid, + session_module._session_id, + session_module._session_start_ms, + ) + errors: list[BaseException] = [] + barrier = threading.Barrier(8) + + def hammer() -> None: + try: + barrier.wait() + for _ in range(100): + assert session_module.get_session_start_ms() > 0 + assert session_module.get_session_id() + except BaseException as exc: # noqa: BLE001 — collected and re-raised below + errors.append(exc) + + # Reset to uninitialized so the racing threads perform first-touch init. + session_module._session_pid = None + session_module._session_id = None + session_module._session_start_ms = None + try: + threads = [threading.Thread(target=hammer) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + finally: + ( + session_module._session_pid, + session_module._session_id, + session_module._session_start_ms, + ) = saved + + assert not errors, f"session init raced: {errors!r}" diff --git a/tests/unit/test_key_generator_blake2b.py b/tests/unit/test_key_generator_blake2b.py index e12b3ba..4dc25d8 100644 --- a/tests/unit/test_key_generator_blake2b.py +++ b/tests/unit/test_key_generator_blake2b.py @@ -4,11 +4,14 @@ import time -import numpy as np import pytest from cachekit.key_generator import CacheKeyGenerator +# Requires the [data] extra — absent e.g. in the free-threaded CI lane until +# numpy ships free-threaded wheels usable here (LAB-511). +np = pytest.importorskip("numpy") + class TestBlake2bKeyGeneration: """Test Blake2b key generation.""" diff --git a/tests/unit/test_mmap_read_path.py b/tests/unit/test_mmap_read_path.py index 3b8289b..8f08731 100644 --- a/tests/unit/test_mmap_read_path.py +++ b/tests/unit/test_mmap_read_path.py @@ -25,6 +25,7 @@ @pytest.mark.unit class TestSupportsMmapRead: def test_arrow_pandas_plaintext_is_eligible(self) -> None: + pytest.importorskip("pyarrow") # "arrow" handler init requires the [data] extra (LAB-511) sh = CacheSerializationHandler(serializer_name="arrow") assert sh.supports_mmap_read() is True @@ -34,12 +35,14 @@ def test_default_serializer_not_eligible(self) -> None: def test_encrypted_arrow_not_eligible(self) -> None: """Encrypted values can never mmap — AES-GCM decrypt owns its buffer.""" + pytest.importorskip("pyarrow") # "arrow" handler init requires the [data] extra (LAB-511) sh = CacheSerializationHandler(serializer_name="arrow") sh.encryption = True assert sh.supports_mmap_read() is False def test_arrow_return_format_not_eligible(self) -> None: """A pyarrow.Table aliases the mmap; closing the handle would be a use-after-free. Pandas only.""" + pytest.importorskip("pyarrow") # "arrow" handler init requires the [data] extra (LAB-511) from cachekit.serializers.arrow_serializer import ArrowSerializer sh = CacheSerializationHandler(serializer_name="arrow") diff --git a/tests/unit/test_orjson_serializer.py b/tests/unit/test_orjson_serializer.py index 9524bf6..fd30d45 100644 --- a/tests/unit/test_orjson_serializer.py +++ b/tests/unit/test_orjson_serializer.py @@ -10,8 +10,12 @@ import pytest -from cachekit.serializers import OrjsonSerializer -from cachekit.serializers.base import SerializationError, SerializationFormat +# OrjsonSerializer requires the [json] extra — absent e.g. in the free-threaded +# CI lane until orjson ships free-threaded wheels (LAB-511). +pytest.importorskip("orjson") + +from cachekit.serializers import OrjsonSerializer # noqa: E402 +from cachekit.serializers.base import SerializationError, SerializationFormat # noqa: E402 class TestOrjsonSerializerBasics: diff --git a/tests/unit/test_saas_observability.py b/tests/unit/test_saas_observability.py index 5a23a23..ba4a36b 100644 --- a/tests/unit/test_saas_observability.py +++ b/tests/unit/test_saas_observability.py @@ -4,6 +4,8 @@ import os import re +import uuid +from contextlib import contextmanager import pytest @@ -649,6 +651,79 @@ def child(q): assert wrapped.cache_info().session_id == parent.session_id +class TestMidPublishMemoryOrdering: + """LAB-511: a reader observing a partially published session identity must recover. + + Under the GIL the assignment order in _ensure_session_initialized makes the + partial state (pid+id visible, start_ms not yet) unobservable; on free-threaded + CPython with a weak memory model the stores can become visible out of order. + Rather than trying to win a hardware race, these tests pin the exact state such + a reader would see and assert it recovers via the lock path instead of hitting + the "should never happen" RuntimeError that made backend.py silently drop the + session headers (the LAB-506 telemetry loss, resurfacing GIL-free). + """ + + @contextmanager + def _mid_publish_state(self): + from cachekit.decorators import session as session_module + + saved = ( + session_module._session_pid, + session_module._session_id, + session_module._session_start_ms, + ) + session_module._session_pid = os.getpid() + session_module._session_id = str(uuid.uuid4()) + session_module._session_start_ms = None + try: + yield + finally: + ( + session_module._session_pid, + session_module._session_id, + session_module._session_start_ms, + ) = saved + + def test_get_session_start_ms_recovers_from_partial_publish(self): + from cachekit.decorators.session import get_session_start_ms + + with self._mid_publish_state(): + start_ms = get_session_start_ms() # pre-fix: RuntimeError + + assert isinstance(start_ms, int) + assert start_ms > 0 + + def test_session_identity_fully_published_after_recovery(self): + from cachekit.decorators import session as session_module + from cachekit.decorators.session import get_session_id + + with self._mid_publish_state(): + get_session_id() + # Recovery runs the full initialization under the lock: every field + # of the identity is populated, none left half-published. + assert session_module._session_pid == os.getpid() + assert session_module._session_id is not None + assert session_module._session_start_ms is not None + + def test_session_headers_present_mid_publish(self): + with self._mid_publish_state(): + headers = get_session_headers() + + assert headers["X-CacheKit-Session-ID"] + assert headers["X-CacheKit-Session-Start"].isdigit() + + def test_no_header_dropped_when_init_observed_mid_publish(self): + """The end-to-end regression: backend.py catches session errors and sends + NO session headers — the silent telemetry loss LAB-506 exists to prevent.""" + with self._mid_publish_state(): + stats = _FunctionStats("lab511.mid_publish_probe") + stats.record_miss() + headers = _inject_metrics_headers(stats) + + assert "X-CacheKit-Session-ID" in headers + assert "X-CacheKit-Session-Start" in headers + + class TestSessionIDUnification: """LAB-506: one process session UUID across decorator and backend paths.""" diff --git a/tests/unit/test_serializer_integrity.py b/tests/unit/test_serializer_integrity.py index 232eff3..c867c4e 100644 --- a/tests/unit/test_serializer_integrity.py +++ b/tests/unit/test_serializer_integrity.py @@ -5,11 +5,16 @@ from __future__ import annotations -import pandas as pd import pytest -from cachekit.serializers import ArrowSerializer, OrjsonSerializer -from cachekit.serializers.base import SerializationError +# Requires the [data] + [json] extras — absent e.g. in the free-threaded CI +# lane until pandas/pyarrow/orjson ship free-threaded wheels (LAB-511). +pd = pytest.importorskip("pandas") +pytest.importorskip("pyarrow") +pytest.importorskip("orjson") + +from cachekit.serializers import ArrowSerializer, OrjsonSerializer # noqa: E402 +from cachekit.serializers.base import SerializationError # noqa: E402 class TestOrjsonSerializerIntegrity: diff --git a/tests/unit/test_serializer_lazy_loading.py b/tests/unit/test_serializer_lazy_loading.py index e22bf43..b1d7447 100644 --- a/tests/unit/test_serializer_lazy_loading.py +++ b/tests/unit/test_serializer_lazy_loading.py @@ -11,7 +11,13 @@ import pytest -from cachekit.serializers import ( +# Lazy loading can only be exercised when the lazily-loaded serializers are +# actually installed — requires the [data] + [json] extras, absent e.g. in the +# free-threaded CI lane (LAB-511). +pytest.importorskip("pyarrow") +pytest.importorskip("orjson") + +from cachekit.serializers import ( # noqa: E402 SERIALIZER_REGISTRY, _get_arrow_serializer, _get_orjson_serializer, @@ -20,9 +26,9 @@ get_serializer, get_serializer_info, ) -from cachekit.serializers.arrow_serializer import ArrowSerializer -from cachekit.serializers.base import SerializerProtocol -from cachekit.serializers.orjson_serializer import OrjsonSerializer +from cachekit.serializers.arrow_serializer import ArrowSerializer # noqa: E402 +from cachekit.serializers.base import SerializerProtocol # noqa: E402 +from cachekit.serializers.orjson_serializer import OrjsonSerializer # noqa: E402 class TestLazyArrowSerializerLoading: diff --git a/tests/unit/test_serializer_protocol.py b/tests/unit/test_serializer_protocol.py index 27a48d5..5a7f098 100644 --- a/tests/unit/test_serializer_protocol.py +++ b/tests/unit/test_serializer_protocol.py @@ -7,9 +7,15 @@ from typing import Any -from cachekit.serializers.arrow_serializer import ArrowSerializer -from cachekit.serializers.auto_serializer import AutoSerializer -from cachekit.serializers.base import SerializationFormat, SerializationMetadata, SerializerProtocol +import pytest + +# ArrowSerializer requires the [data] extra — absent e.g. in the free-threaded +# CI lane until pyarrow ships free-threaded wheels (LAB-511). +pytest.importorskip("pyarrow") + +from cachekit.serializers.arrow_serializer import ArrowSerializer # noqa: E402 +from cachekit.serializers.auto_serializer import AutoSerializer # noqa: E402 +from cachekit.serializers.base import SerializationFormat, SerializationMetadata, SerializerProtocol # noqa: E402 class TestSerializerProtocolCompliance: diff --git a/tests/unit/test_xxhash_integrity.py b/tests/unit/test_xxhash_integrity.py index 30ffae1..2f5839a 100644 --- a/tests/unit/test_xxhash_integrity.py +++ b/tests/unit/test_xxhash_integrity.py @@ -13,11 +13,16 @@ from __future__ import annotations -import pandas as pd import pytest -from cachekit.serializers import ArrowSerializer, OrjsonSerializer -from cachekit.serializers.base import SerializationError +# Requires the [data] + [json] extras — absent e.g. in the free-threaded CI +# lane until pandas/pyarrow/orjson ship free-threaded wheels (LAB-511). +pd = pytest.importorskip("pandas") +pytest.importorskip("pyarrow") +pytest.importorskip("orjson") + +from cachekit.serializers import ArrowSerializer, OrjsonSerializer # noqa: E402 +from cachekit.serializers.base import SerializationError # noqa: E402 class TestOrjsonSerializerXxhashIntegrity: diff --git a/uv.lock b/uv.lock index 4f9df25..7c0083d 100644 --- a/uv.lock +++ b/uv.lock @@ -293,6 +293,25 @@ dev = [ fuzz = [ { name = "atheris" }, ] +test = [ + { name = "faker" }, + { name = "fakeredis" }, + { name = "httpx" }, + { name = "hypothesis" }, + { name = "psutil" }, + { name = "pymemcache" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-benchmark" }, + { name = "pytest-cov" }, + { name = "pytest-markdown-docs" }, + { name = "pytest-redis" }, + { name = "pytest-xdist" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "time-machine" }, +] [package.metadata] requires-dist = [ @@ -344,6 +363,25 @@ dev = [ { name = "time-machine", specifier = ">=2.19.0" }, ] fuzz = [{ name = "atheris", specifier = ">=2.3.0" }] +test = [ + { name = "faker", specifier = ">=20.0.0" }, + { name = "fakeredis", specifier = ">=2.21.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "hypothesis", specifier = ">=6.0.0" }, + { name = "psutil", specifier = ">=5.9.0" }, + { name = "pymemcache", specifier = ">=4.0.0" }, + { name = "pytest", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytest-benchmark", specifier = ">=4.0.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-markdown-docs", specifier = ">=0.6.0" }, + { name = "pytest-redis", specifier = ">=3.0.0" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "requests", marker = "python_full_version >= '3.10'", specifier = ">=2.33.0" }, + { name = "time-machine", specifier = ">=2.19.0" }, +] [[package]] name = "cachetools" From 23734a507f5bba2e72865b0f2007e074801a0440 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 04:06:18 +1000 Subject: [PATCH 2/2] =?UTF-8?q?fix(tests):=20expert-panel=20findings=20?= =?UTF-8?q?=E2=80=94=20per-worker=20GIL=20guard,=20un-over-skip=20encrypti?= =?UTF-8?q?on/protocol=20suites=20(LAB-511)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel findings applied: - tests/conftest.py: autouse session-scoped fixture fails the run if the GIL got re-enabled on a free-threaded build — runs in EVERY xdist worker and both suites, covering lazily-imported extensions the single-process CI pre-flight and one-worker in-suite check missed (bug-hunter MAJ). - test_encryption_security_invariants.py / test_serializer_protocol.py: module-level importorskip narrowed to the 2+1 tests that actually need orjson/pyarrow — the encryption invariants and protocol-compliance suites now run on the free-threaded lane (craftsman MAJ x2; +35 tests). - README/docs: support claim narrowed to 3.14t — the only build the lane runs (craftsman MIN). Rejected: cutting test_session_headers_present_mid_publish as duplicate — it pins the get_session_headers fallback branch; the end-to-end test pins the info.session_id branch. Distinct paths, both stay. --- README.md | 2 +- docs/free-threading.md | 5 ++-- tests/conftest.py | 29 +++++++++++++++++++ .../test_encryption_security_invariants.py | 10 ++++--- tests/unit/test_serializer_protocol.py | 15 +++++----- 5 files changed, 47 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4059b5e..feaf321 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ exposition setup.
Thread Safety Details -**Free-threaded CPython (3.13t/3.14t):** the core suites run green on +**Free-threaded CPython (3.14t):** the core suites run green on free-threaded 3.14 with the GIL verified disabled (CI job `test-freethreaded`), and the Rust extension declares free-threaded safety (`gil_used = false`). Free-threaded wheels are **not yet published** and diff --git a/docs/free-threading.md b/docs/free-threading.md index 6f82b89..7249268 100644 --- a/docs/free-threading.md +++ b/docs/free-threading.md @@ -1,6 +1,7 @@ -# Free-Threaded CPython (3.13t / 3.14t) +# Free-Threaded CPython -Status as of LAB-511 (2026-08): **tested, not yet declared**. +Status as of LAB-511 (2026-08): **tested on 3.14t, not yet declared**. +(3.13t is not in the CI matrix — no claim is made for it.) - The core test suites (`tests/unit/`, `tests/critical/`) run green on free-threaded CPython 3.14 with the GIL verified disabled, gated by the diff --git a/tests/conftest.py b/tests/conftest.py index ce3b595..772df11 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ """ import os +import sys import pytest @@ -33,6 +34,34 @@ pass +# ============================================================================= +# Free-threaded build guard (LAB-511) +# ============================================================================= + + +@pytest.fixture(scope="session", autouse=True) +def _gil_stays_disabled_on_free_threaded_builds(): + """On a free-threaded build, fail the session if anything re-enabled the GIL. + + Any C extension without a Py_mod_gil declaration (a conftest import, a + plugin, a future dependency bump) re-enables the GIL for the whole + process at import time — silently turning the free-threaded CI lane back + into a GIL run while it keeps reporting green. Checked at teardown so + every lazily-imported module is covered, and autouse at session scope so + it runs in EVERY pytest-xdist worker process and in every suite. No-op + on GIL builds. + """ + yield + import sysconfig + + if sysconfig.get_config_var("Py_GIL_DISABLED") and sys._is_gil_enabled(): # type: ignore[attr-defined] + pytest.fail( + "The GIL was re-enabled during a free-threaded test session — " + "some imported extension module does not declare free-threaded " + "support (Py_mod_gil). This worker silently ran under the GIL." + ) + + # ============================================================================= # Configuration Management # ============================================================================= diff --git a/tests/unit/test_encryption_security_invariants.py b/tests/unit/test_encryption_security_invariants.py index 5219f11..4c9be2d 100644 --- a/tests/unit/test_encryption_security_invariants.py +++ b/tests/unit/test_encryption_security_invariants.py @@ -19,10 +19,8 @@ from cachekit.serializers.wrapper import SerializationWrapper # OrjsonSerializer requires the [json] extra — absent e.g. in the free-threaded -# CI lane until orjson ships free-threaded wheels (LAB-511). -pytest.importorskip("orjson") - -from cachekit.serializers.orjson_serializer import OrjsonSerializer # noqa: E402 +# CI lane (LAB-511). Skipped per-test, NOT module-level: the rest of this +# module pins encryption invariants that must keep running without orjson. @pytest.fixture(autouse=True) @@ -151,6 +149,7 @@ def deserialize(self, data, metadata=None): def test_orjson_string_accepted_with_encryption(self, monkeypatch): """String serializer 'orjson' (cross-SDK) is accepted under encryption (Issue #134).""" + pytest.importorskip("orjson") monkeypatch.setenv("CACHEKIT_MASTER_KEY", "a" * 64) from cachekit.config.singleton import reset_settings @@ -167,6 +166,9 @@ def test_orjson_string_accepted_with_encryption(self, monkeypatch): def test_cross_sdk_instance_accepted_and_threaded_into_wrapper(self, monkeypatch): """A cross_sdk_compatible serializer instance is accepted AND used by the wrapper (Issue #134).""" + pytest.importorskip("orjson") + from cachekit.serializers.orjson_serializer import OrjsonSerializer + monkeypatch.setenv("CACHEKIT_MASTER_KEY", "a" * 64) from cachekit.config.singleton import reset_settings diff --git a/tests/unit/test_serializer_protocol.py b/tests/unit/test_serializer_protocol.py index 5a7f098..86c469f 100644 --- a/tests/unit/test_serializer_protocol.py +++ b/tests/unit/test_serializer_protocol.py @@ -9,13 +9,8 @@ import pytest -# ArrowSerializer requires the [data] extra — absent e.g. in the free-threaded -# CI lane until pyarrow ships free-threaded wheels (LAB-511). -pytest.importorskip("pyarrow") - -from cachekit.serializers.arrow_serializer import ArrowSerializer # noqa: E402 -from cachekit.serializers.auto_serializer import AutoSerializer # noqa: E402 -from cachekit.serializers.base import SerializationFormat, SerializationMetadata, SerializerProtocol # noqa: E402 +from cachekit.serializers.auto_serializer import AutoSerializer +from cachekit.serializers.base import SerializationFormat, SerializationMetadata, SerializerProtocol class TestSerializerProtocolCompliance: @@ -28,6 +23,12 @@ def test_auto_serializer_implements_protocol(self): def test_arrow_serializer_implements_protocol(self): """ArrowSerializer must implement SerializerProtocol.""" + # Requires the [data] extra — absent e.g. in the free-threaded CI lane + # (LAB-511). Skipped here, not module-level: the rest of this module + # is pyarrow-free and must keep running without the extra. + pytest.importorskip("pyarrow") + from cachekit.serializers.arrow_serializer import ArrowSerializer + serializer = ArrowSerializer() assert isinstance(serializer, SerializerProtocol)