cachekit 0.6.1__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cachekit-0.6.1 → cachekit-0.7.0}/Cargo.lock +1 -1
- {cachekit-0.6.1 → cachekit-0.7.0}/PKG-INFO +1 -1
- {cachekit-0.6.1 → cachekit-0.7.0}/pyproject.toml +1 -1
- {cachekit-0.6.1 → cachekit-0.7.0}/rust/Cargo.toml +1 -1
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/__init__.py +1 -1
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/cachekitio/backend.py +90 -22
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/provider.py +26 -8
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/cache_handler.py +23 -4
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/config/decorator.py +7 -2
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/config/nested.py +7 -2
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/wrapper.py +66 -70
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/key_generator.py +27 -2
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/__init__.py +2 -1
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/auto_serializer.py +32 -1
- {cachekit-0.6.1 → cachekit-0.7.0}/Cargo.toml +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/LICENSE +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/README.md +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/rust/Makefile +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/rust/README.md +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/rust/TEST_EXPANSION_SUMMARY.md +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/rust/src/lib.rs +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/rust/src/python_bindings.rs +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/rust/supply-chain/audits.toml +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/rust/supply-chain/config.toml +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/rust/supply-chain/imports.lock +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/rust/tsan_suppressions.txt +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/__init__.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/base.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/base_config.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/cachekitio/__init__.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/cachekitio/client.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/cachekitio/config.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/cachekitio/error_handler.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/cachekitio/session.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/errors.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/file/__init__.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/file/backend.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/file/config.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/memcached/__init__.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/memcached/backend.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/memcached/config.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/memcached/error_handler.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/redis/__init__.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/redis/backend.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/redis/client.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/redis/config.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/redis/error_handler.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/redis/provider.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/config/__init__.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/config/settings.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/config/singleton.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/config/validation.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/__init__.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/intent.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/local_wrapper.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/main.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/orchestrator.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/session.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/stats_context.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/tenant_context.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/utils/__init__.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/di.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/hash_utils.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/health.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/hiredis_compat.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/imports.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/invalidation/__init__.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/invalidation/channel.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/invalidation/event.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/invalidation/redis_channel.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/l1_cache.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/logging.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/monitoring/__init__.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/monitoring/correlation_tracking.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/monitoring/pool_monitor.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/monitoring/protocols.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/object_cache.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/py.typed +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/__init__.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/adaptive_timeout.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/async_metrics.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/circuit_breaker.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/error_classification.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/load_control.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/metrics_collection.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/profiles.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/arrow_serializer.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/base.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/encryption_wrapper.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/orjson_serializer.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/standard_serializer.py +0 -0
- {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/wrapper.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cachekit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "Production-ready Redis caching for Python with intelligent reliability features and Rust-powered performance"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -2,16 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import json
|
|
7
|
+
import math
|
|
8
|
+
import random
|
|
6
9
|
import time
|
|
7
|
-
from
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
13
|
+
from urllib.parse import quote
|
|
8
14
|
|
|
9
15
|
import httpx
|
|
10
16
|
|
|
11
17
|
from cachekit.backends.cachekitio.client import get_cached_async_http_client, get_sync_http_client
|
|
12
18
|
from cachekit.backends.cachekitio.config import CachekitIOBackendConfig
|
|
13
19
|
from cachekit.backends.cachekitio.error_handler import classify_http_error
|
|
14
|
-
from cachekit.backends.errors import BackendError
|
|
20
|
+
from cachekit.backends.errors import BackendError, BackendErrorType
|
|
15
21
|
from cachekit.decorators.stats_context import get_current_function_stats
|
|
16
22
|
from cachekit.logging import get_structured_logger
|
|
17
23
|
|
|
@@ -544,42 +550,104 @@ class CachekitIOBackend:
|
|
|
544
550
|
|
|
545
551
|
# ==================== LockableBackend Protocol ====================
|
|
546
552
|
|
|
547
|
-
async def
|
|
548
|
-
"""
|
|
553
|
+
async def _try_acquire_lock(self, lock_key: str, timeout: float) -> str | None:
|
|
554
|
+
"""Single attempt at the SaaS lock endpoint. Returns lock_id, or None if held.
|
|
549
555
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
556
|
+
Non-finite (NaN / ±inf) or non-positive ``timeout`` is clamped to 1ms — without
|
|
557
|
+
the guard, ``int(NaN)`` / ``int(inf)`` would raise outside ``BackendError`` and
|
|
558
|
+
escape the wrapper's degrade-to-no-lock branch.
|
|
553
559
|
|
|
554
|
-
|
|
555
|
-
|
|
560
|
+
Raises:
|
|
561
|
+
BackendError: For AUTHENTICATION and PERMANENT failures (bad key, bad lock_key
|
|
562
|
+
format) — polling won't recover and the wrapper degrades to no-lock execution.
|
|
563
|
+
TRANSIENT/TIMEOUT/UNKNOWN are swallowed as None so the polling loop can retry.
|
|
556
564
|
"""
|
|
565
|
+
# Clamp non-positive / non-finite timeouts before the int conversion.
|
|
566
|
+
# int(NaN) / int(inf) raise ValueError/OverflowError that aren't BackendError, so
|
|
567
|
+
# they'd escape the wrapper's degrade-to-no-lock branch and crash the @cache.io call.
|
|
568
|
+
timeout_ms = max(1, int(timeout * 1000)) if math.isfinite(timeout) else 1
|
|
569
|
+
encoded_key = quote(lock_key, safe="")
|
|
557
570
|
try:
|
|
558
|
-
payload = json.dumps({"timeout_ms": timeout * 1000})
|
|
559
571
|
response = await self._request_async(
|
|
560
572
|
"POST",
|
|
561
|
-
f"{
|
|
562
|
-
content=
|
|
573
|
+
f"{encoded_key}/lock",
|
|
574
|
+
content=json.dumps({"timeout_ms": timeout_ms}).encode(),
|
|
563
575
|
headers={"Content-Type": "application/json"},
|
|
564
576
|
)
|
|
577
|
+
except BackendError as exc:
|
|
578
|
+
# Re-raise unrecoverable failures so the wrapper can log + degrade once,
|
|
579
|
+
# instead of burning the full blocking_timeout on billable retries.
|
|
580
|
+
if exc.error_type in (BackendErrorType.AUTHENTICATION, BackendErrorType.PERMANENT):
|
|
581
|
+
raise
|
|
582
|
+
return None
|
|
583
|
+
|
|
584
|
+
try:
|
|
565
585
|
data = response.json()
|
|
566
|
-
|
|
567
|
-
except
|
|
586
|
+
lock_id = data.get("lock_id")
|
|
587
|
+
except (ValueError, AttributeError):
|
|
588
|
+
# Malformed body from the SaaS — treat as held (retry) rather than crashing
|
|
589
|
+
# the wrapper. Same failure class as the original issue #129 if we re-raised.
|
|
568
590
|
return None
|
|
569
591
|
|
|
570
|
-
|
|
571
|
-
|
|
592
|
+
return lock_id if isinstance(lock_id, str) else None
|
|
593
|
+
|
|
594
|
+
@asynccontextmanager
|
|
595
|
+
async def acquire_lock(
|
|
596
|
+
self,
|
|
597
|
+
key: str,
|
|
598
|
+
timeout: float,
|
|
599
|
+
blocking_timeout: Optional[float] = None,
|
|
600
|
+
) -> AsyncIterator[bool]:
|
|
601
|
+
"""Acquire distributed lock (LockableBackend protocol).
|
|
602
|
+
|
|
603
|
+
The SaaS endpoint returns immediately; client-side polling implements
|
|
604
|
+
``blocking_timeout`` with proportional jitter (0.5×–1× the capped delay) to
|
|
605
|
+
avoid lockstep retries on concurrent waiters. Each retry is a billable SaaS
|
|
606
|
+
request — keep the cap tight.
|
|
572
607
|
|
|
573
608
|
Args:
|
|
574
|
-
|
|
575
|
-
|
|
609
|
+
key: Lock key
|
|
610
|
+
timeout: Server-side hold duration before auto-release (seconds)
|
|
611
|
+
blocking_timeout: Max client-side wait to acquire (None = single attempt)
|
|
576
612
|
|
|
577
|
-
|
|
578
|
-
True if
|
|
613
|
+
Yields:
|
|
614
|
+
True if acquired, False if ``blocking_timeout`` elapsed without acquisition
|
|
615
|
+
"""
|
|
616
|
+
lock_id: str | None = None
|
|
617
|
+
try:
|
|
618
|
+
lock_id = await self._try_acquire_lock(key, timeout)
|
|
619
|
+
|
|
620
|
+
if lock_id is None and blocking_timeout is not None:
|
|
621
|
+
deadline = time.monotonic() + blocking_timeout
|
|
622
|
+
delay = 0.05
|
|
623
|
+
while lock_id is None:
|
|
624
|
+
remaining = deadline - time.monotonic()
|
|
625
|
+
if remaining <= 0:
|
|
626
|
+
break
|
|
627
|
+
# Proportional jitter (not crypto): spread concurrent waiters, 0.5×–1× the capped delay.
|
|
628
|
+
jitter = 0.5 + random.random() * 0.5 # noqa: S311 — backoff jitter, not security
|
|
629
|
+
await asyncio.sleep(min(delay, remaining) * jitter)
|
|
630
|
+
lock_id = await self._try_acquire_lock(key, timeout)
|
|
631
|
+
delay = min(delay * 2, 0.5)
|
|
632
|
+
|
|
633
|
+
yield lock_id is not None
|
|
634
|
+
finally:
|
|
635
|
+
if lock_id is not None:
|
|
636
|
+
await self._release_lock(key, lock_id)
|
|
637
|
+
|
|
638
|
+
async def _release_lock(self, lock_key: str, lock_id: str) -> bool:
|
|
639
|
+
"""Release distributed lock. Internal helper for ``acquire_lock``'s cleanup.
|
|
640
|
+
|
|
641
|
+
Best-effort: swallows ``BackendError`` and returns False so a release failure
|
|
642
|
+
inside ``__aexit__`` cannot mask the user's exception. The server-side ``timeout``
|
|
643
|
+
on the lock is the safety net if the DELETE never lands.
|
|
579
644
|
"""
|
|
645
|
+
# URL-encode both segments: lock_key is caller-controlled, lock_id is server-issued
|
|
646
|
+
# but the SaaS contract doesn't pin a charset.
|
|
647
|
+
encoded_key = quote(lock_key, safe="")
|
|
648
|
+
encoded_id = quote(lock_id, safe="")
|
|
580
649
|
try:
|
|
581
|
-
|
|
582
|
-
await self._request_async("DELETE", f"{lock_key}/lock?lock_id={lock_id}")
|
|
650
|
+
await self._request_async("DELETE", f"{encoded_key}/lock?lock_id={encoded_id}")
|
|
583
651
|
return True
|
|
584
652
|
except BackendError:
|
|
585
653
|
return False
|
|
@@ -102,32 +102,50 @@ class DefaultCacheClientProvider(CacheClientProvider):
|
|
|
102
102
|
|
|
103
103
|
|
|
104
104
|
class DefaultBackendProvider(BackendProviderInterface):
|
|
105
|
-
"""Default backend provider
|
|
105
|
+
"""Default backend provider with env-based auto-detection.
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
Priority order (first matching env var wins):
|
|
108
|
+
1. CACHEKIT_API_KEY → CachekitIOBackend (SaaS)
|
|
109
|
+
2. CACHEKIT_REDIS_URL or REDIS_URL → RedisBackend
|
|
109
110
|
|
|
110
111
|
For single-tenant deployments (default), sets tenant_context to "default".
|
|
111
112
|
For multi-tenant deployments, tenant_context must be set externally.
|
|
112
113
|
"""
|
|
113
114
|
|
|
114
115
|
def __init__(self):
|
|
115
|
-
self.
|
|
116
|
+
self._cachekitio_backend = None
|
|
117
|
+
self._redis_provider = None
|
|
116
118
|
|
|
117
119
|
def get_backend(self):
|
|
118
|
-
"""Get
|
|
119
|
-
|
|
120
|
+
"""Get backend instance, auto-detected from environment on first call.
|
|
121
|
+
|
|
122
|
+
CachekitIO backends are stateless singletons (cached).
|
|
123
|
+
Redis backends are per-request tenant-scoped wrappers (not cached —
|
|
124
|
+
RedisBackendProvider.get_backend() reads tenant_context ContextVar).
|
|
125
|
+
"""
|
|
126
|
+
import os
|
|
127
|
+
|
|
128
|
+
# Priority 1: CachekitIO SaaS backend (stateless, safe to cache)
|
|
129
|
+
if os.environ.get("CACHEKIT_API_KEY"):
|
|
130
|
+
if self._cachekitio_backend is None:
|
|
131
|
+
from cachekit.backends.cachekitio import CachekitIOBackend
|
|
132
|
+
|
|
133
|
+
self._cachekitio_backend = CachekitIOBackend()
|
|
134
|
+
return self._cachekitio_backend
|
|
135
|
+
|
|
136
|
+
# Priority 2: Redis backend (tenant-scoped, call provider each time)
|
|
137
|
+
if self._redis_provider is None:
|
|
120
138
|
from cachekit.backends.redis.config import RedisBackendConfig
|
|
121
139
|
from cachekit.backends.redis.provider import RedisBackendProvider, tenant_context
|
|
122
140
|
|
|
123
141
|
redis_config = RedisBackendConfig.from_env()
|
|
124
|
-
self.
|
|
142
|
+
self._redis_provider = RedisBackendProvider(redis_url=redis_config.redis_url)
|
|
125
143
|
|
|
126
144
|
# Set default tenant for single-tenant mode (if not already set)
|
|
127
145
|
if tenant_context.get() is None:
|
|
128
146
|
tenant_context.set("default")
|
|
129
147
|
|
|
130
|
-
return self.
|
|
148
|
+
return self._redis_provider.get_backend()
|
|
131
149
|
|
|
132
150
|
|
|
133
151
|
__all__ = [
|
|
@@ -303,13 +303,25 @@ class CacheSerializationHandler:
|
|
|
303
303
|
but extraction fails, ValueError propagates to caller (no fallback to shared key).
|
|
304
304
|
"""
|
|
305
305
|
self.serializer_name = serializer_name
|
|
306
|
+
self.enable_integrity_checking = enable_integrity_checking
|
|
307
|
+
self._deployment_uuid_value: Optional[str] = None
|
|
308
|
+
|
|
309
|
+
# Auto-detect encryption from CACHEKIT_MASTER_KEY when not explicitly configured.
|
|
310
|
+
# This is the single convergence point for ALL backends and presets.
|
|
311
|
+
if not encryption and master_key is None and tenant_extractor is None:
|
|
312
|
+
from cachekit.config.singleton import get_settings
|
|
313
|
+
|
|
314
|
+
settings = get_settings()
|
|
315
|
+
if settings.master_key:
|
|
316
|
+
encryption = True
|
|
317
|
+
master_key = settings.master_key.get_secret_value()
|
|
318
|
+
single_tenant_mode = True
|
|
319
|
+
|
|
306
320
|
self.encryption = encryption
|
|
307
321
|
self.tenant_extractor = tenant_extractor
|
|
308
322
|
self.single_tenant_mode = single_tenant_mode
|
|
309
323
|
self.deployment_uuid = deployment_uuid
|
|
310
324
|
self.master_key = master_key
|
|
311
|
-
self.enable_integrity_checking = enable_integrity_checking
|
|
312
|
-
self._deployment_uuid_value: Optional[str] = None
|
|
313
325
|
|
|
314
326
|
# Extract string name for metadata storage (for protocol instances, use class name)
|
|
315
327
|
if isinstance(serializer_name, str):
|
|
@@ -680,8 +692,9 @@ class CacheSerializationHandler:
|
|
|
680
692
|
else:
|
|
681
693
|
# Data is not encrypted - use base serializer directly (no cache_key needed)
|
|
682
694
|
return base_serializer.deserialize(serialized_data, metadata)
|
|
683
|
-
except ValueError:
|
|
684
|
-
# cache_key missing for encrypted data
|
|
695
|
+
except (ValueError, SerializationError):
|
|
696
|
+
# ValueError: cache_key missing for encrypted data — FAIL CLOSED
|
|
697
|
+
# SerializationError/EncryptionError: let the outer handler log and handle
|
|
685
698
|
raise
|
|
686
699
|
except Exception as e:
|
|
687
700
|
get_logger().error(f"Deserialization failed with {self.serializer_name}: {e}")
|
|
@@ -806,6 +819,9 @@ class CacheOperationHandler:
|
|
|
806
819
|
# Return a tuple (True, value) to distinguish from "no cache entry"
|
|
807
820
|
return (True, deserialized)
|
|
808
821
|
return None
|
|
822
|
+
except SerializationError as e:
|
|
823
|
+
get_logger().warning(f"L2 cache decrypt/integrity failure for {cache_key}: {e}")
|
|
824
|
+
return None
|
|
809
825
|
except Exception as e:
|
|
810
826
|
get_logger().warning(f"Backend operation failed for get on {cache_key}: {e}")
|
|
811
827
|
return None
|
|
@@ -836,6 +852,9 @@ class CacheOperationHandler:
|
|
|
836
852
|
# Return a tuple (True, value) to distinguish from "no cache entry"
|
|
837
853
|
return (True, deserialized)
|
|
838
854
|
return None
|
|
855
|
+
except SerializationError as e:
|
|
856
|
+
get_logger().warning(f"L2 cache decrypt/integrity failure for {cache_key}: {e}")
|
|
857
|
+
return None
|
|
839
858
|
except Exception as e:
|
|
840
859
|
get_logger().warning(f"Backend operation failed for get on {cache_key}: {e}")
|
|
841
860
|
return None
|
|
@@ -285,7 +285,7 @@ class DecoratorConfig:
|
|
|
285
285
|
"enable_prometheus_metrics": self.monitoring.enable_prometheus_metrics,
|
|
286
286
|
# Encryption (flattened)
|
|
287
287
|
"encryption": self.encryption.enabled,
|
|
288
|
-
"master_key": self.encryption.master_key,
|
|
288
|
+
"master_key": "[REDACTED]" if self.encryption.master_key else None,
|
|
289
289
|
"tenant_extractor": self.encryption.tenant_extractor,
|
|
290
290
|
}
|
|
291
291
|
|
|
@@ -382,7 +382,7 @@ class DecoratorConfig:
|
|
|
382
382
|
Use cases: PII, medical data, financial records, GDPR compliance
|
|
383
383
|
Architecture: Both L1 and L2 store encrypted bytes (encrypt-at-rest everywhere)
|
|
384
384
|
|
|
385
|
-
Note: Backend resolved from REDIS_URL
|
|
385
|
+
Note: Backend resolved from CACHEKIT_API_KEY, REDIS_URL, set_default_backend(), or explicit backend= kwarg
|
|
386
386
|
Note: integrity_checking is forced to True (non-negotiable for security)
|
|
387
387
|
|
|
388
388
|
Args:
|
|
@@ -539,6 +539,10 @@ class DecoratorConfig:
|
|
|
539
539
|
CACHEKIT_API_KEY: API key for authentication (required)
|
|
540
540
|
CACHEKIT_API_URL: API endpoint (default: https://api.cachekit.io)
|
|
541
541
|
|
|
542
|
+
Encryption: Set CACHEKIT_MASTER_KEY env var to enable automatic client-side
|
|
543
|
+
AES-256-GCM encryption — no code changes needed. Auto-detection happens in
|
|
544
|
+
CacheSerializationHandler and applies to ALL presets, not just .io().
|
|
545
|
+
|
|
542
546
|
Args:
|
|
543
547
|
**kwargs: Overrides (ttl, namespace, etc.)
|
|
544
548
|
|
|
@@ -572,6 +576,7 @@ class DecoratorConfig:
|
|
|
572
576
|
backend = CachekitIOBackend()
|
|
573
577
|
|
|
574
578
|
# Use production-grade settings with SaaS backend
|
|
579
|
+
# Encryption auto-detected from CACHEKIT_MASTER_KEY in CacheSerializationHandler
|
|
575
580
|
return cls(
|
|
576
581
|
backend=backend,
|
|
577
582
|
integrity_checking=True,
|
|
@@ -7,7 +7,7 @@ timeout, backpressure, monitoring, encryption).
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from dataclasses import dataclass
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
11
|
from typing import Callable
|
|
12
12
|
|
|
13
13
|
from .validation import ConfigurationError
|
|
@@ -285,6 +285,11 @@ class EncryptionConfig:
|
|
|
285
285
|
NOTE: Per backend abstraction spec, encryption stores encrypted bytes in BOTH L1 and L2.
|
|
286
286
|
L1 can be enabled with encryption (stores encrypted bytes, not plaintext).
|
|
287
287
|
|
|
288
|
+
Tenant mode is required: set single_tenant_mode=True for single-tenant or provide
|
|
289
|
+
a tenant_extractor callable for multi-tenant key isolation. @cache.secure() sets
|
|
290
|
+
single_tenant_mode automatically; if using EncryptionConfig directly (e.g. with
|
|
291
|
+
@cache.io), you must set it explicitly.
|
|
292
|
+
|
|
288
293
|
Attributes:
|
|
289
294
|
enabled: Enable client-side encryption (default: False)
|
|
290
295
|
master_key: Hex-encoded master key for key derivation (required if enabled)
|
|
@@ -322,7 +327,7 @@ class EncryptionConfig:
|
|
|
322
327
|
"""
|
|
323
328
|
|
|
324
329
|
enabled: bool = False
|
|
325
|
-
master_key: str | None = None
|
|
330
|
+
master_key: str | None = field(default=None, repr=False)
|
|
326
331
|
tenant_extractor: Callable[..., str] | None = None
|
|
327
332
|
single_tenant_mode: bool = False
|
|
328
333
|
deployment_uuid: str | None = None
|
|
@@ -20,7 +20,9 @@ from ..cache_handler import (
|
|
|
20
20
|
)
|
|
21
21
|
from ..key_generator import CacheKeyGenerator
|
|
22
22
|
from ..l1_cache import get_l1_cache
|
|
23
|
+
from ..object_cache import ObjectCache
|
|
23
24
|
from ..reliability import CircuitBreakerConfig
|
|
25
|
+
from ..serializers.base import SerializationError
|
|
24
26
|
|
|
25
27
|
# Config import removed - using direct DecoratorConfig integration
|
|
26
28
|
from .orchestrator import FeatureOrchestrator
|
|
@@ -479,6 +481,10 @@ def create_cache_wrapper(
|
|
|
479
481
|
# FIX: Initialize L1 cache if enabled
|
|
480
482
|
_l1_cache = get_l1_cache(namespace or "default") if l1_enabled else None
|
|
481
483
|
|
|
484
|
+
# L1-only mode: use ObjectCache for raw Python object storage (no serialization).
|
|
485
|
+
# This preserves types (tuples, sets, frozensets) that MessagePack would degrade.
|
|
486
|
+
_object_cache: ObjectCache | None = ObjectCache(max_entries=256) if _l1_only_mode else None
|
|
487
|
+
|
|
482
488
|
# Create per-function statistics tracker with lazy session ID generation
|
|
483
489
|
# Session ID format: "{process_uuid}:{module}.{function_name}"
|
|
484
490
|
# Generated lazily on first use or regenerated after cache_clear()
|
|
@@ -569,41 +575,22 @@ def create_cache_wrapper(
|
|
|
569
575
|
reset_current_function_stats(token)
|
|
570
576
|
return func(*args, **kwargs)
|
|
571
577
|
|
|
572
|
-
# L1-ONLY MODE:
|
|
573
|
-
#
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
try:
|
|
582
|
-
# Pass cache_key for AAD verification (required for encryption)
|
|
583
|
-
l1_value = operation_handler.serialization_handler.deserialize_data(l1_bytes, cache_key=cache_key)
|
|
584
|
-
_stats.record_l1_hit()
|
|
585
|
-
reset_current_function_stats(token)
|
|
586
|
-
return l1_value
|
|
587
|
-
except Exception:
|
|
588
|
-
# L1 deserialization failed - invalidate and continue
|
|
589
|
-
_l1_cache.invalidate(cache_key)
|
|
578
|
+
# L1-ONLY MODE: Store raw Python objects (no serialization).
|
|
579
|
+
# Preserves types (tuples, sets, frozensets) that MessagePack would degrade.
|
|
580
|
+
if _l1_only_mode and _object_cache:
|
|
581
|
+
found, cached_value = _object_cache.get(cache_key)
|
|
582
|
+
if found:
|
|
583
|
+
_stats.record_l1_hit()
|
|
584
|
+
features.clear_correlation_id()
|
|
585
|
+
reset_current_function_stats(token)
|
|
586
|
+
return cached_value
|
|
590
587
|
|
|
591
|
-
#
|
|
588
|
+
# Cache miss - execute function and store raw result
|
|
592
589
|
_stats.record_miss()
|
|
593
590
|
try:
|
|
594
591
|
result = func(*args, **kwargs)
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
# Pass cache_key for AAD binding (required for encryption)
|
|
598
|
-
serialized_bytes = operation_handler.serialization_handler.serialize_data(
|
|
599
|
-
result, args, kwargs, cache_key=cache_key
|
|
600
|
-
)
|
|
601
|
-
if _l1_cache and cache_key and serialized_bytes:
|
|
602
|
-
_l1_cache.put(cache_key, serialized_bytes, redis_ttl=ttl)
|
|
603
|
-
_cached_keys.add(cache_key)
|
|
604
|
-
except Exception as e:
|
|
605
|
-
# Serialization/storage failed but function succeeded - log and return result
|
|
606
|
-
logger().debug(f"L1-only mode: serialization/storage failed for {cache_key}: {e}")
|
|
592
|
+
_object_cache.put(cache_key, result, ttl=ttl if ttl is not None else 31536000)
|
|
593
|
+
_cached_keys.add(cache_key)
|
|
607
594
|
return result
|
|
608
595
|
finally:
|
|
609
596
|
features.clear_correlation_id()
|
|
@@ -913,39 +900,20 @@ def create_cache_wrapper(
|
|
|
913
900
|
)
|
|
914
901
|
return await func(*args, **kwargs)
|
|
915
902
|
|
|
916
|
-
# L1-ONLY MODE:
|
|
917
|
-
#
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
# Pass cache_key for AAD verification (required for encryption)
|
|
927
|
-
l1_value = operation_handler.serialization_handler.deserialize_data(l1_bytes, cache_key=cache_key)
|
|
928
|
-
_stats.record_l1_hit()
|
|
929
|
-
return l1_value
|
|
930
|
-
except Exception:
|
|
931
|
-
# L1 deserialization failed - invalidate and continue
|
|
932
|
-
_l1_cache.invalidate(cache_key)
|
|
933
|
-
|
|
934
|
-
# L1 cache miss - execute function and store in L1
|
|
903
|
+
# L1-ONLY MODE: Store raw Python objects (no serialization).
|
|
904
|
+
# Preserves types (tuples, sets, frozensets) that MessagePack would degrade.
|
|
905
|
+
if _l1_only_mode and _object_cache:
|
|
906
|
+
found, cached_value = _object_cache.get(cache_key)
|
|
907
|
+
if found:
|
|
908
|
+
_stats.record_l1_hit()
|
|
909
|
+
features.clear_correlation_id()
|
|
910
|
+
return cached_value
|
|
911
|
+
|
|
912
|
+
# Cache miss - execute function and store raw result
|
|
935
913
|
_stats.record_miss()
|
|
936
914
|
result = await func(*args, **kwargs)
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
# Pass cache_key for AAD binding (required for encryption)
|
|
940
|
-
serialized_bytes = operation_handler.serialization_handler.serialize_data(
|
|
941
|
-
result, args, kwargs, cache_key=cache_key
|
|
942
|
-
)
|
|
943
|
-
if _l1_cache and cache_key and serialized_bytes:
|
|
944
|
-
_l1_cache.put(cache_key, serialized_bytes, redis_ttl=ttl)
|
|
945
|
-
_cached_keys.add(cache_key)
|
|
946
|
-
except Exception as e:
|
|
947
|
-
# Serialization/storage failed but function succeeded - log and return result
|
|
948
|
-
logger().debug(f"L1-only mode: serialization/storage failed for {cache_key}: {e}")
|
|
915
|
+
_object_cache.put(cache_key, result, ttl=ttl if ttl is not None else 31536000)
|
|
916
|
+
_cached_keys.add(cache_key)
|
|
949
917
|
return result
|
|
950
918
|
|
|
951
919
|
# L1+L2 MODE: Original behavior with backend initialization
|
|
@@ -1066,8 +1034,21 @@ def create_cache_wrapper(
|
|
|
1066
1034
|
|
|
1067
1035
|
return result
|
|
1068
1036
|
|
|
1037
|
+
except SerializationError as e:
|
|
1038
|
+
# Decrypt/integrity failure on L2 data — warn explicitly (fail-open: recompute)
|
|
1039
|
+
logger().warning(f"L2 cache decrypt/integrity failure for {cache_key}: {e}")
|
|
1040
|
+
get_duration_ms = (time.perf_counter() - start_time) * 1000
|
|
1041
|
+
features.handle_cache_error(
|
|
1042
|
+
error=e,
|
|
1043
|
+
operation="cache_get_deserialize",
|
|
1044
|
+
cache_key=cache_key or "unknown",
|
|
1045
|
+
namespace=namespace or "default",
|
|
1046
|
+
duration_ms=get_duration_ms,
|
|
1047
|
+
correlation_id=correlation_id,
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1069
1050
|
except Exception as e:
|
|
1070
|
-
#
|
|
1051
|
+
# Backend/network error - record but continue to function execution
|
|
1071
1052
|
get_duration_ms = (time.perf_counter() - start_time) * 1000
|
|
1072
1053
|
features.handle_cache_error(
|
|
1073
1054
|
error=e,
|
|
@@ -1113,6 +1094,8 @@ def create_cache_wrapper(
|
|
|
1113
1094
|
_cached_keys.add(cache_key)
|
|
1114
1095
|
|
|
1115
1096
|
return result
|
|
1097
|
+
except SerializationError as e:
|
|
1098
|
+
logger().warning(f"L2 cache decrypt/integrity failure for {cache_key}: {e}")
|
|
1116
1099
|
except Exception as e:
|
|
1117
1100
|
# If double-check fails, continue to execute function
|
|
1118
1101
|
_logger.debug("Double-check cache failed after lock acquisition: %s", e)
|
|
@@ -1139,6 +1122,8 @@ def create_cache_wrapper(
|
|
|
1139
1122
|
_cached_keys.add(cache_key)
|
|
1140
1123
|
|
|
1141
1124
|
return result
|
|
1125
|
+
except SerializationError as e:
|
|
1126
|
+
logger().warning(f"L2 cache decrypt/integrity failure for {cache_key}: {e}")
|
|
1142
1127
|
except Exception:
|
|
1143
1128
|
# Cache check failed - fall through to execute function
|
|
1144
1129
|
logger().warning(
|
|
@@ -1314,7 +1299,9 @@ def create_cache_wrapper(
|
|
|
1314
1299
|
# Snapshot prevents RuntimeError if another thread adds during iteration
|
|
1315
1300
|
keys_snapshot = set(_cached_keys)
|
|
1316
1301
|
for key in keys_snapshot:
|
|
1317
|
-
if
|
|
1302
|
+
if _object_cache:
|
|
1303
|
+
_object_cache.delete(key)
|
|
1304
|
+
elif _l1_cache:
|
|
1318
1305
|
_l1_cache.invalidate(key)
|
|
1319
1306
|
if _backend and not _l1_only_mode:
|
|
1320
1307
|
invalidator.set_backend(_backend)
|
|
@@ -1329,7 +1316,9 @@ def create_cache_wrapper(
|
|
|
1329
1316
|
# Single-key invalidation (specific args provided, or zero-param function)
|
|
1330
1317
|
cache_key = operation_handler.get_cache_key(func, args, kwargs, namespace, integrity_checking)
|
|
1331
1318
|
|
|
1332
|
-
if
|
|
1319
|
+
if _object_cache and cache_key:
|
|
1320
|
+
_object_cache.delete(cache_key)
|
|
1321
|
+
elif _l1_cache and cache_key:
|
|
1333
1322
|
_l1_cache.invalidate(cache_key)
|
|
1334
1323
|
_cached_keys.discard(cache_key)
|
|
1335
1324
|
|
|
@@ -1355,7 +1344,9 @@ def create_cache_wrapper(
|
|
|
1355
1344
|
if not args and not kwargs and _func_has_params:
|
|
1356
1345
|
keys_snapshot = set(_cached_keys)
|
|
1357
1346
|
for key in keys_snapshot:
|
|
1358
|
-
if
|
|
1347
|
+
if _object_cache:
|
|
1348
|
+
_object_cache.delete(key)
|
|
1349
|
+
elif _l1_cache:
|
|
1359
1350
|
_l1_cache.invalidate(key)
|
|
1360
1351
|
if _backend and not _l1_only_mode:
|
|
1361
1352
|
invalidator.set_backend(_backend)
|
|
@@ -1370,7 +1361,9 @@ def create_cache_wrapper(
|
|
|
1370
1361
|
# Single-key invalidation (specific args provided, or zero-param function)
|
|
1371
1362
|
cache_key = operation_handler.get_cache_key(func, args, kwargs, namespace, integrity_checking)
|
|
1372
1363
|
|
|
1373
|
-
if
|
|
1364
|
+
if _object_cache and cache_key:
|
|
1365
|
+
_object_cache.delete(cache_key)
|
|
1366
|
+
elif _l1_cache and cache_key:
|
|
1374
1367
|
_l1_cache.invalidate(cache_key)
|
|
1375
1368
|
_cached_keys.discard(cache_key)
|
|
1376
1369
|
|
|
@@ -1432,9 +1425,12 @@ def create_cache_wrapper(
|
|
|
1432
1425
|
def cache_clear() -> None:
|
|
1433
1426
|
"""Clear cache statistics and invalidate all cached entries."""
|
|
1434
1427
|
_stats.clear()
|
|
1435
|
-
#
|
|
1436
|
-
|
|
1437
|
-
|
|
1428
|
+
# In L1-only mode, invalidation is synchronous (no backend I/O needed)
|
|
1429
|
+
# so cache_clear() works for both sync and async functions.
|
|
1430
|
+
if inspect.iscoroutinefunction(func) and not _l1_only_mode:
|
|
1431
|
+
raise TypeError(
|
|
1432
|
+
"cache_clear() cannot clear cache for async functions with a backend. Use 'await fn.ainvalidate_cache()' instead."
|
|
1433
|
+
)
|
|
1438
1434
|
invalidate_cache()
|
|
1439
1435
|
|
|
1440
1436
|
if inspect.iscoroutinefunction(func):
|
|
@@ -43,6 +43,12 @@ class CacheKeyGenerator:
|
|
|
43
43
|
"local": "l", # Reference caching (no serialization)
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
# Regex for chars allowed in the func component of a SaaS cache key.
|
|
47
|
+
# SaaS validates: /^[a-zA-Z0-9_.]{1,200}$/
|
|
48
|
+
_FUNC_ALLOWED_RE = __import__("re").compile(r"[^A-Za-z0-9_.]")
|
|
49
|
+
_DOUBLE_DOT_RE = __import__("re").compile(r"\.{2,}")
|
|
50
|
+
_FUNC_NAME_MAX = 200
|
|
51
|
+
|
|
46
52
|
def __init__(self):
|
|
47
53
|
"""Initialize the key generator.
|
|
48
54
|
|
|
@@ -83,8 +89,9 @@ class CacheKeyGenerator:
|
|
|
83
89
|
if namespace:
|
|
84
90
|
key_parts.extend(["ns:", namespace, ":"])
|
|
85
91
|
|
|
86
|
-
# Add function identifier (module + name)
|
|
87
|
-
|
|
92
|
+
# Add function identifier (module + name) — sanitized for SaaS key format
|
|
93
|
+
func_name = self._sanitize_func_name(func.__module__, func.__qualname__)
|
|
94
|
+
key_parts.extend(["func:", func_name, ":"])
|
|
88
95
|
|
|
89
96
|
# Generate args hash using Blake2b-256
|
|
90
97
|
args_hash = self._blake2b_hash(args, kwargs)
|
|
@@ -347,3 +354,21 @@ class CacheKeyGenerator:
|
|
|
347
354
|
normalized = f"{prefix}:{key_hash[:32]}"
|
|
348
355
|
|
|
349
356
|
return normalized
|
|
357
|
+
|
|
358
|
+
@classmethod
|
|
359
|
+
def _sanitize_func_name(cls, module: str, qualname: str) -> str:
|
|
360
|
+
"""Sanitize module.qualname for SaaS cache-key compliance.
|
|
361
|
+
|
|
362
|
+
The SaaS ``func`` component must match ``[a-zA-Z0-9_.]{1,200}``
|
|
363
|
+
and must not contain ``..``. Nested functions have qualnames like
|
|
364
|
+
``outer.<locals>.inner`` and lambdas are ``<lambda>`` — the angle
|
|
365
|
+
brackets violate the regex.
|
|
366
|
+
|
|
367
|
+
This replaces every disallowed char with ``_``, collapses runs of
|
|
368
|
+
``..`` into a single ``.``, and truncates to 200 chars. The mapping
|
|
369
|
+
is deterministic: same function → same key.
|
|
370
|
+
"""
|
|
371
|
+
raw = f"{module}.{qualname}"
|
|
372
|
+
sanitized = cls._FUNC_ALLOWED_RE.sub("_", raw)
|
|
373
|
+
sanitized = cls._DOUBLE_DOT_RE.sub(".", sanitized)
|
|
374
|
+
return sanitized[: cls._FUNC_NAME_MAX]
|
|
@@ -52,6 +52,7 @@ _serializer_lock = Lock()
|
|
|
52
52
|
# This allows passing enable_integrity_checking parameter during instantiation
|
|
53
53
|
SERIALIZER_REGISTRY = {
|
|
54
54
|
"auto": AutoSerializer, # Python-specific types (NumPy, pandas, datetime optimization)
|
|
55
|
+
"pythonic": AutoSerializer, # Alias — preserves Python types (tuples, sets, frozensets, datetime, UUID)
|
|
55
56
|
"default": StandardSerializer, # Language-agnostic MessagePack for multi-language caches
|
|
56
57
|
"std": StandardSerializer, # Explicit StandardSerializer alias
|
|
57
58
|
"arrow": None, # Lazy-loaded: requires pyarrow from [data] extra
|
|
@@ -121,7 +122,7 @@ def get_serializer(name: str, enable_integrity_checking: bool = True) -> Seriali
|
|
|
121
122
|
serializer_class = SERIALIZER_REGISTRY[name]
|
|
122
123
|
|
|
123
124
|
# Instantiate with integrity checking configuration
|
|
124
|
-
if name in ("default", "std", "auto", "arrow", "orjson"):
|
|
125
|
+
if name in ("default", "std", "auto", "pythonic", "arrow", "orjson"):
|
|
125
126
|
# All core serializers use enable_integrity_checking parameter
|
|
126
127
|
serializer = serializer_class(enable_integrity_checking=enable_integrity_checking)
|
|
127
128
|
else:
|
|
@@ -9,6 +9,7 @@ Auto-detects and optimizes:
|
|
|
9
9
|
- datetime/date/time (ISO-8601)
|
|
10
10
|
- UUID (string representation)
|
|
11
11
|
- set/frozenset (type-safe roundtrip)
|
|
12
|
+
- tuple (recursive type-safe roundtrip)
|
|
12
13
|
|
|
13
14
|
Uses MessagePack as the default format with graceful degradation for optional dependencies.
|
|
14
15
|
|
|
@@ -73,7 +74,7 @@ ORM_ERROR_MESSAGE = (
|
|
|
73
74
|
|
|
74
75
|
CUSTOM_CLASS_ERROR_MESSAGE = (
|
|
75
76
|
"AutoSerializer does not support custom classes. "
|
|
76
|
-
"Supported types: dict, list, str, int, float, bool, None, bytes, "
|
|
77
|
+
"Supported types: dict, list, tuple, str, int, float, bool, None, bytes, "
|
|
77
78
|
"datetime, date, time, UUID, set, frozenset, NumPy arrays, pandas DataFrames.\n"
|
|
78
79
|
"Options:\n"
|
|
79
80
|
" 1. Convert to dict manually\n"
|
|
@@ -124,6 +125,26 @@ def _safe_hasattr(obj: Any, attr: str) -> bool:
|
|
|
124
125
|
return False
|
|
125
126
|
|
|
126
127
|
|
|
128
|
+
def _wrap_tuples(obj: Any) -> Any:
|
|
129
|
+
"""Recursively wrap tuples in type markers before msgpack encoding.
|
|
130
|
+
|
|
131
|
+
Msgpack natively serializes tuples as arrays (same as lists), so the
|
|
132
|
+
``default`` callback is never called for them. This pre-processor
|
|
133
|
+
converts tuples to ``{"__tuple__": True, "value": [...]}`` markers
|
|
134
|
+
that ``_auto_object_hook`` restores on deserialization.
|
|
135
|
+
|
|
136
|
+
Only affects tuples — all other types pass through unchanged and are
|
|
137
|
+
handled by msgpack's ``default`` callback (``_auto_default``).
|
|
138
|
+
"""
|
|
139
|
+
if isinstance(obj, tuple):
|
|
140
|
+
return {"__tuple__": True, "value": [_wrap_tuples(x) for x in obj]}
|
|
141
|
+
if isinstance(obj, list):
|
|
142
|
+
return [_wrap_tuples(x) for x in obj]
|
|
143
|
+
if isinstance(obj, dict):
|
|
144
|
+
return {k: _wrap_tuples(v) for k, v in obj.items()}
|
|
145
|
+
return obj
|
|
146
|
+
|
|
147
|
+
|
|
127
148
|
def _auto_default(obj: Any) -> Any:
|
|
128
149
|
"""Custom encoder for types not natively supported by MessagePack.
|
|
129
150
|
|
|
@@ -226,6 +247,14 @@ def _auto_object_hook(obj: Any) -> Any:
|
|
|
226
247
|
except (ValueError, TypeError) as e:
|
|
227
248
|
raise SerializationError(f"Invalid UUID format in cached data: {value}") from e
|
|
228
249
|
|
|
250
|
+
if obj.get("__tuple__") is True:
|
|
251
|
+
if "value" not in obj:
|
|
252
|
+
raise SerializationError("Invalid tuple format: missing 'value' field in cached data")
|
|
253
|
+
value_list = obj["value"]
|
|
254
|
+
if not isinstance(value_list, list):
|
|
255
|
+
raise SerializationError(f"Invalid tuple format: expected list, got {type(value_list).__name__}")
|
|
256
|
+
return tuple(value_list)
|
|
257
|
+
|
|
229
258
|
if obj.get("__set__") is True:
|
|
230
259
|
if "value" not in obj:
|
|
231
260
|
raise SerializationError("Invalid set format: missing 'value' field in cached data")
|
|
@@ -748,6 +777,8 @@ class AutoSerializer:
|
|
|
748
777
|
|
|
749
778
|
def _serialize_msgpack(self, obj: Any) -> bytes:
|
|
750
779
|
"""Serialize general object with MessagePack."""
|
|
780
|
+
# Pre-process tuples into markers (msgpack natively flattens them to lists)
|
|
781
|
+
obj = _wrap_tuples(obj)
|
|
751
782
|
msgpack_data = msgpack.packb(obj, **self._msgpack_pack_opts)
|
|
752
783
|
|
|
753
784
|
if self.enable_integrity_checking:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|