From 8458e56ee92d2aa33bbb64d93f43ffdc334e6cc9 Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Mon, 10 Aug 2026 14:39:33 -0300 Subject: [PATCH 1/2] feat(cache): add core module for caching --- pyproject.toml | 1 + src/sap_cloud_sdk/cache/__init__.py | 53 +++++++ src/sap_cloud_sdk/cache/_backend.py | 35 +++++ src/sap_cloud_sdk/cache/_cache.py | 174 +++++++++++++++++++++ src/sap_cloud_sdk/cache/_config.py | 67 ++++++++ src/sap_cloud_sdk/cache/_isolation.py | 67 ++++++++ src/sap_cloud_sdk/cache/_lru_backend.py | 111 +++++++++++++ src/sap_cloud_sdk/cache/exceptions.py | 9 ++ src/sap_cloud_sdk/cache/py.typed | 0 src/sap_cloud_sdk/cache/user-guide.md | 150 ++++++++++++++++++ src/sap_cloud_sdk/core/telemetry/module.py | 1 + tests/cache/__init__.py | 0 tests/cache/unit/__init__.py | 0 tests/cache/unit/test_backend.py | 102 ++++++++++++ tests/cache/unit/test_cache.py | 156 ++++++++++++++++++ tests/cache/unit/test_config.py | 59 +++++++ tests/cache/unit/test_isolation.py | 81 ++++++++++ uv.lock | 47 +++--- 18 files changed, 1088 insertions(+), 25 deletions(-) create mode 100644 src/sap_cloud_sdk/cache/__init__.py create mode 100644 src/sap_cloud_sdk/cache/_backend.py create mode 100644 src/sap_cloud_sdk/cache/_cache.py create mode 100644 src/sap_cloud_sdk/cache/_config.py create mode 100644 src/sap_cloud_sdk/cache/_isolation.py create mode 100644 src/sap_cloud_sdk/cache/_lru_backend.py create mode 100644 src/sap_cloud_sdk/cache/exceptions.py create mode 100644 src/sap_cloud_sdk/cache/py.typed create mode 100644 src/sap_cloud_sdk/cache/user-guide.md create mode 100644 tests/cache/__init__.py create mode 100644 tests/cache/unit/__init__.py create mode 100644 tests/cache/unit/test_backend.py create mode 100644 tests/cache/unit/test_cache.py create mode 100644 tests/cache/unit/test_config.py create mode 100644 tests/cache/unit/test_isolation.py diff --git a/pyproject.toml b/pyproject.toml index c5ca925a..0a1b4acc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "opentelemetry-instrumentation-flask~=0.64b0", "mcp>=1.1.0", "cryptography>=46.0.3", + "cachetools~=5.5.2", ] [project.optional-dependencies] diff --git a/src/sap_cloud_sdk/cache/__init__.py b/src/sap_cloud_sdk/cache/__init__.py new file mode 100644 index 00000000..2ce5234e --- /dev/null +++ b/src/sap_cloud_sdk/cache/__init__.py @@ -0,0 +1,53 @@ +"""SAP Cloud SDK for Python - Cache module. + +Provides a domain-agnostic, pluggable cache layer shared across all SDK +modules. Supports tenant and tenant-user isolation, configurable TTL and +expiry buffers, LRU eviction, and custom backends for multi-instance +deployments. + +Global configuration example:: + + from sap_cloud_sdk.cache import CacheConfig, configure_cache + + configure_cache(CacheConfig( + default_ttl_seconds=600, + expiry_buffer_seconds=60, + max_size=2000, + )) + +Disabling the cache for a specific client:: + + from sap_cloud_sdk.destination import create_client + from sap_cloud_sdk.cache import CacheConfig + + client = create_client(cache_config=CacheConfig(enabled=False)) + +Custom backend example (Redis):: + + from sap_cloud_sdk.cache import CacheBackend, CacheConfig, configure_cache + + class RedisCacheBackend(CacheBackend): + def get(self, key): ... + def set(self, key, value, ttl_seconds): ... + def delete(self, key): ... + def clear(self): ... + + configure_cache(CacheConfig(backend=RedisCacheBackend(...))) +""" + +from sap_cloud_sdk.cache._backend import CacheBackend +from sap_cloud_sdk.cache._config import CacheConfig, configure_cache, get_cache_config +from sap_cloud_sdk.cache._isolation import IsolationStrategy +from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend +from sap_cloud_sdk.cache.exceptions import BackendError, CacheError + +__all__ = [ + "CacheBackend", + "CacheConfig", + "configure_cache", + "get_cache_config", + "IsolationStrategy", + "InMemoryLRUBackend", + "CacheError", + "BackendError", +] diff --git a/src/sap_cloud_sdk/cache/_backend.py b/src/sap_cloud_sdk/cache/_backend.py new file mode 100644 index 00000000..50b9e9e9 --- /dev/null +++ b/src/sap_cloud_sdk/cache/_backend.py @@ -0,0 +1,35 @@ +"""Abstract cache backend interface.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class CacheBackend(ABC): + """Domain-agnostic key/value cache backend. + + The backend has no awareness of tenants, TTL policy, namespaces, or domain + types. All of that is handled by the :class:`~sap_cloud_sdk.cache._cache.Cache` + façade before keys and values reach the backend. + + Implement this to plug in any shared cache (Redis, Memcached, etc.) for + multi-instance deployments (Kyma ``replicas > 1``, Cloud Foundry + ``instances > 1``). + """ + + @abstractmethod + def get(self, key: str) -> Any | None: + """Return the value for *key*, or ``None`` if absent or expired.""" + + @abstractmethod + def set(self, key: str, value: Any, ttl_seconds: int) -> None: + """Store *value* under *key* with a time-to-live in seconds.""" + + @abstractmethod + def delete(self, key: str) -> None: + """Remove the entry for *key* (no-op if absent).""" + + @abstractmethod + def clear(self) -> None: + """Remove all entries.""" diff --git a/src/sap_cloud_sdk/cache/_cache.py b/src/sap_cloud_sdk/cache/_cache.py new file mode 100644 index 00000000..4e4b12a9 --- /dev/null +++ b/src/sap_cloud_sdk/cache/_cache.py @@ -0,0 +1,174 @@ +"""SDK-internal cache façade. + +SDK modules instantiate :class:`Cache` per-client and call its +``get``/``set``/``evict``/``reset`` methods. The façade handles: + +- Selecting the active backend (per-client override or global default). +- Building namespaced, isolation-scoped keys. +- Applying the expiry buffer before forwarding TTLs to the backend. +- Honouring the ``enabled`` flag. + +This class is **not part of the public API** and is not exported from +``sap_cloud_sdk.cache``. Import it directly:: + + from sap_cloud_sdk.cache._cache import Cache +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sap_cloud_sdk.cache._config import CacheConfig, get_cache_config +from sap_cloud_sdk.cache._isolation import build_isolation_key +from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend +from sap_cloud_sdk.cache.exceptions import BackendError + +logger = logging.getLogger(__name__) + + +class Cache: + """Per-client cache façade. + + Args: + config: Per-client override. When ``None``, the global config set via + :func:`~sap_cloud_sdk.cache._config.configure_cache` is used. + The config is snapshotted at construction time — subsequent calls + to :func:`configure_cache` do not affect an existing ``Cache``. + """ + + def __init__(self, config: CacheConfig | None = None) -> None: + self._config: CacheConfig = config if config is not None else get_cache_config() + self._backend = self._resolve_backend() + + # ------------------------------------------------------------------ + # Public façade methods + # ------------------------------------------------------------------ + + def get( + self, + namespace: str, + key: str, + tenant_id: str, + user_id: str | None = None, + ) -> Any | None: + """Return a cached value, or ``None`` on miss or when disabled. + + Args: + namespace: Domain namespace, e.g. ``"destination"``. + key: Domain-specific key, e.g. the destination name. + tenant_id: Tenant identifier for isolation key derivation. + user_id: Optional user identifier. Drives ``TENANT_USER`` + isolation when present (and no explicit strategy override). + """ + if not self._config.enabled: + return None + + full_key = self._make_full_key(namespace, key, tenant_id, user_id) + try: + return self._backend.get(full_key) + except Exception as e: + logger.warning("cache backend get() raised an exception: %s", e) + return None + + def set( + self, + namespace: str, + key: str, + value: Any, + ttl_seconds: int | None, + tenant_id: str, + user_id: str | None = None, + ) -> None: + """Store a value in the cache. + + The effective TTL forwarded to the backend is: + - *ttl_seconds* − ``expiry_buffer_seconds`` when *ttl_seconds* is given. + - ``default_ttl_seconds`` − ``expiry_buffer_seconds`` as fallback. + + The result is clamped to a minimum of 1 second. + + Args: + namespace: Domain namespace. + key: Domain-specific key. + value: Value to cache (must be serialisable by the backend). + ttl_seconds: Natural TTL derived from the resource (e.g. token + ``exp`` minus now). Pass ``None`` to use the configured + default. + tenant_id: Tenant identifier for isolation key derivation. + user_id: Optional user identifier. + """ + if not self._config.enabled: + return + + raw_ttl = ( + ttl_seconds if ttl_seconds is not None else self._config.default_ttl_seconds + ) + effective_ttl = max(raw_ttl - self._config.expiry_buffer_seconds, 1) + + full_key = self._make_full_key(namespace, key, tenant_id, user_id) + try: + self._backend.set(full_key, value, effective_ttl) + except Exception as e: + raise BackendError(f"cache backend set() failed: {e}") from e + + def evict( + self, + namespace: str, + key: str, + tenant_id: str, + user_id: str | None = None, + ) -> None: + """Remove a single entry (no-op if absent or cache is disabled). + + Args: + namespace: Domain namespace. + key: Domain-specific key. + tenant_id: Tenant identifier. + user_id: Optional user identifier. + """ + if not self._config.enabled: + return + + full_key = self._make_full_key(namespace, key, tenant_id, user_id) + try: + self._backend.delete(full_key) + except Exception as e: + logger.warning("cache backend delete() raised an exception: %s", e) + + def reset(self) -> None: + """Clear all entries from the backend. + + Use with care in production — this forces a full re-fetch of every + cached resource on the next access. + """ + try: + self._backend.clear() + except Exception as e: + logger.warning("cache backend clear() raised an exception: %s", e) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _resolve_backend(self) -> Any: + if self._config.backend is not None: + return self._config.backend + return InMemoryLRUBackend( + max_size=self._config.max_size, + on_evict=self._config.on_evict, + ) + + def _make_full_key( + self, + namespace: str, + key: str, + tenant_id: str, + user_id: str | None, + ) -> str: + isolation_key = build_isolation_key( + tenant_id=tenant_id, + user_id=user_id, + strategy=self._config.isolation_strategy, + ) + return f"{namespace}::{isolation_key}::{key}" diff --git a/src/sap_cloud_sdk/cache/_config.py b/src/sap_cloud_sdk/cache/_config.py new file mode 100644 index 00000000..5fb6713e --- /dev/null +++ b/src/sap_cloud_sdk/cache/_config.py @@ -0,0 +1,67 @@ +"""Global cache configuration and registry.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from sap_cloud_sdk.cache._backend import CacheBackend + from sap_cloud_sdk.cache._isolation import IsolationStrategy + + +@dataclass +class CacheConfig: + """Configuration for the SDK cache layer. + + Can be set globally via :func:`configure_cache` or passed per-client + to override the global for that client only. + + Attributes: + enabled: Master on/off switch. When ``False``, all gets return + ``None`` and all sets are no-ops. + isolation_strategy: Override the automatic isolation selection. + ``None`` means auto-detect from context (``TENANT_USER`` when a + user ID is present, ``TENANT`` otherwise). + default_ttl_seconds: Fallback TTL used when the caller does not + supply a natural TTL (e.g. from a token ``exp`` claim). + expiry_buffer_seconds: Seconds subtracted from any derived TTL to + pre-invalidate entries before they expire on the remote service. + max_size: Maximum number of entries in the built-in in-memory + backend before LRU eviction kicks in. + backend: Custom cache backend. ``None`` uses the built-in + :class:`~sap_cloud_sdk.cache._lru_backend.InMemoryLRUBackend`. + on_evict: Optional callback invoked when an entry is evicted. + Signature: ``(key: str, reason: str) -> None`` where *reason* + is one of ``"ttl"``, ``"lru"``, or ``"manual"``. + """ + + enabled: bool = True + isolation_strategy: IsolationStrategy | None = None + default_ttl_seconds: int = 300 + expiry_buffer_seconds: int = 30 + max_size: int = 1000 + backend: CacheBackend | None = None + on_evict: Callable[[str, str], None] | None = field(default=None, repr=False) + + +_global_config: CacheConfig = CacheConfig() + + +def configure_cache(config: CacheConfig) -> None: + """Set the global cache configuration. + + Must be called before any SDK client is created. Hot-reload is not + supported — changes after clients are constructed have no effect on + already-instantiated :class:`~sap_cloud_sdk.cache._cache.Cache` objects. + + Args: + config: The new global configuration. + """ + global _global_config + _global_config = config + + +def get_cache_config() -> CacheConfig: + """Return the current global cache configuration.""" + return _global_config diff --git a/src/sap_cloud_sdk/cache/_isolation.py b/src/sap_cloud_sdk/cache/_isolation.py new file mode 100644 index 00000000..5c2a266c --- /dev/null +++ b/src/sap_cloud_sdk/cache/_isolation.py @@ -0,0 +1,67 @@ +"""Cache isolation strategy and key construction.""" + +from __future__ import annotations + +import hashlib +import logging +from enum import Enum + +logger = logging.getLogger(__name__) + + +class IsolationStrategy(str, Enum): + """Controls how cache keys are scoped to tenants and users. + + ``TENANT`` + One namespace per tenant. Used when no user context is present. + + ``TENANT_USER`` + One namespace per (tenant, user) pair. Prevents cross-user cache hits + within the same tenant. + """ + + TENANT = "tenant" + TENANT_USER = "tenant_user" + + +def build_isolation_key( + tenant_id: str, + user_id: str | None = None, + strategy: IsolationStrategy | None = None, +) -> str: + """Derive a cache isolation key from tenant/user context. + + When *strategy* is ``None``, the strategy is selected automatically: + ``TENANT_USER`` if *user_id* is non-empty, ``TENANT`` otherwise. + + Downgrading explicitly from ``TENANT_USER`` to ``TENANT`` when a + *user_id* is available risks cross-user contamination and triggers a + warning. + + Args: + tenant_id: The tenant identifier (required, non-empty). + user_id: Optional user identifier. Drives auto-selection when + *strategy* is ``None``. + strategy: Explicit override. ``None`` means auto-detect. + + Returns: + An opaque string suitable for inclusion in a cache key. + """ + has_user = bool(user_id) + effective = strategy + + if effective is None: + effective = ( + IsolationStrategy.TENANT_USER if has_user else IsolationStrategy.TENANT + ) + elif effective is IsolationStrategy.TENANT and has_user: + logger.warning( + "cache isolation downgraded from TENANT_USER to TENANT while user_id is " + "present — this may cause cross-user cache contamination" + ) + + if effective is IsolationStrategy.TENANT_USER and has_user: + material = f"{tenant_id}|{user_id}" + return hashlib.sha256(material.encode()).hexdigest()[:32] + + return tenant_id diff --git a/src/sap_cloud_sdk/cache/_lru_backend.py b/src/sap_cloud_sdk/cache/_lru_backend.py new file mode 100644 index 00000000..f643d28e --- /dev/null +++ b/src/sap_cloud_sdk/cache/_lru_backend.py @@ -0,0 +1,111 @@ +"""In-memory LRU + TTL cache backend backed by cachetools.""" + +from __future__ import annotations + +import logging +import threading +from typing import Any, Callable + +from cachetools import TTLCache + +from sap_cloud_sdk.cache._backend import CacheBackend + +logger = logging.getLogger(__name__) + + +class _EvictingTTLCache(TTLCache): + """TTLCache subclass that fires an optional callback on eviction.""" + + def __init__( + self, + maxsize: int, + ttl: float, + on_evict: Callable[[str, str], None] | None, + ) -> None: + super().__init__(maxsize=maxsize, ttl=ttl) + self._on_evict = on_evict + + def popitem(self) -> tuple[Any, Any]: + key, value = super().popitem() + if self._on_evict is not None: + try: + self._on_evict(str(key), "lru") + except Exception: + logger.debug("on_evict callback raised an exception", exc_info=True) + return key, value + + +class InMemoryLRUBackend(CacheBackend): + """Thread-safe in-memory LRU + TTL cache. + + Uses :class:`cachetools.TTLCache` under the hood. Each entry has an + individual TTL supplied at write time. Least-recently-used entries are + evicted when *max_size* is exceeded. + + Suitable for single-process (single-instance) deployments. For + horizontally scaled deployments implement a custom + :class:`~sap_cloud_sdk.cache._backend.CacheBackend` backed by a shared + store (Redis, Memcached, etc.) and pass it via + :class:`~sap_cloud_sdk.cache._config.CacheConfig`. + + Args: + max_size: Maximum number of entries before LRU eviction. + on_evict: Optional callback ``(key, reason) -> None``. *reason* is + ``"lru"`` for capacity evictions or ``"manual"`` for explicit + :meth:`delete` / :meth:`clear` calls. TTL expiry is handled + transparently by cachetools and does not fire this callback. + """ + + def __init__( + self, + max_size: int = 1000, + on_evict: Callable[[str, str], None] | None = None, + ) -> None: + # cachetools TTLCache requires a single TTL at construction time; we + # work around this by storing (value, expires_at_monotonic) tuples and + # setting a very large cache-level TTL so cachetools never expires + # entries on its own. Expiry is enforced in get() by comparing + # time.monotonic() against the stored deadline. + self._cache: _EvictingTTLCache = _EvictingTTLCache( + maxsize=max_size, + ttl=86400 * 365, # 1 year — expiry managed manually per-entry + on_evict=on_evict, + ) + self._on_evict = on_evict + self._lock = threading.Lock() + + def get(self, key: str) -> Any | None: + import time + + with self._lock: + entry = self._cache.get(key) + if entry is None: + return None + value, expires_at = entry + if time.monotonic() >= expires_at: + try: + del self._cache[key] + except KeyError: + pass + return None + return value + + def set(self, key: str, value: Any, ttl_seconds: int) -> None: + import time + + expires_at = time.monotonic() + max(ttl_seconds, 1) + with self._lock: + self._cache[key] = (value, expires_at) + + def delete(self, key: str) -> None: + with self._lock: + existed = self._cache.pop(key, None) is not None + if existed and self._on_evict is not None: + try: + self._on_evict(key, "manual") + except Exception: + logger.debug("on_evict callback raised an exception", exc_info=True) + + def clear(self) -> None: + with self._lock: + self._cache.clear() diff --git a/src/sap_cloud_sdk/cache/exceptions.py b/src/sap_cloud_sdk/cache/exceptions.py new file mode 100644 index 00000000..b0f360fa --- /dev/null +++ b/src/sap_cloud_sdk/cache/exceptions.py @@ -0,0 +1,9 @@ +"""Exception classes for the cache module.""" + + +class CacheError(Exception): + """Base exception for all cache module errors.""" + + +class BackendError(CacheError): + """Raised when a custom cache backend raises an unexpected exception.""" diff --git a/src/sap_cloud_sdk/cache/py.typed b/src/sap_cloud_sdk/cache/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/sap_cloud_sdk/cache/user-guide.md b/src/sap_cloud_sdk/cache/user-guide.md new file mode 100644 index 00000000..6ca15c11 --- /dev/null +++ b/src/sap_cloud_sdk/cache/user-guide.md @@ -0,0 +1,150 @@ +# Cache: User Guide + +Provides a domain-agnostic, pluggable cache layer shared across all SAP Cloud SDK modules. Supports tenant and tenant-user isolation, configurable TTL and expiry buffers, LRU eviction, and custom backends for multi-instance deployments. + +## Installation + +```bash +uv add sap-cloud-sdk +``` + +No BTP service binding is required — the cache module is self-contained. + +## Quick Start + +By default every SDK client uses an in-process LRU cache with sensible defaults. No configuration is needed unless you want to tune it. + +```python +from sap_cloud_sdk.cache import CacheConfig, configure_cache + +configure_cache(CacheConfig( + default_ttl_seconds=600, + expiry_buffer_seconds=60, + max_size=2000, +)) +``` + +Call `configure_cache()` **once at startup**, before any SDK client is created. Changes made after client construction have no effect on already-instantiated clients. + +## Configuration Reference + +| Field | Type | Default | Description | +|---|---|---|---| +| `enabled` | `bool` | `True` | Master on/off switch. `False` turns all gets into misses and all sets into no-ops. | +| `isolation_strategy` | `IsolationStrategy \| None` | `None` | Override automatic isolation. `None` = auto-detect from context. | +| `default_ttl_seconds` | `int` | `300` | Fallback TTL when no natural TTL is available. | +| `expiry_buffer_seconds` | `int` | `30` | Subtracted from any TTL before storing, to pre-invalidate entries. | +| `max_size` | `int` | `1000` | Maximum entries in the built-in backend before LRU eviction. | +| `backend` | `CacheBackend \| None` | `None` | Custom backend. `None` uses the built-in `InMemoryLRUBackend`. | +| `on_evict` | `Callable[[str, str], None] \| None` | `None` | Callback fired on eviction. Arguments: `(key, reason)` where reason is `"lru"` or `"manual"`. | + +## Isolation Strategy + +The cache automatically scopes keys to the current tenant (and optionally user) to prevent cross-tenant and cross-user cache hits. + +| Strategy | Scope | Auto-selected when | +|---|---|---| +| `TENANT` | Per tenant | No user ID present | +| `TENANT_USER` | Per (tenant, user) pair | User ID is present | + +Override the strategy globally: + +```python +from sap_cloud_sdk.cache import CacheConfig, IsolationStrategy, configure_cache + +configure_cache(CacheConfig(isolation_strategy=IsolationStrategy.TENANT)) +``` + +Downgrading from `TENANT_USER` to `TENANT` when a user ID is present logs a warning, as it risks cross-user contamination. + +## Disabling the Cache Per Client + +Pass a `CacheConfig(enabled=False)` when constructing a client to disable caching for that client only, without affecting the global configuration: + +```python +from sap_cloud_sdk.destination import create_client +from sap_cloud_sdk.cache import CacheConfig + +client = create_client(cache_config=CacheConfig(enabled=False)) +``` + +## Eviction Callback + +```python +import logging +from sap_cloud_sdk.cache import CacheConfig, configure_cache + +logger = logging.getLogger(__name__) + +def on_evict(key: str, reason: str) -> None: + logger.info("cache evicted key=%s reason=%s", key, reason) + +configure_cache(CacheConfig(on_evict=on_evict)) +``` + +`reason` values: +- `"lru"` — evicted because `max_size` was exceeded +- `"manual"` — removed by an explicit `evict()` or `reset()` call + +## Custom Backend (Multi-Instance Deployments) + +The built-in `InMemoryLRUBackend` is process-local. For horizontally scaled deployments implement `CacheBackend` and pass it via `CacheConfig`: + +```python +from sap_cloud_sdk.cache import CacheBackend, CacheConfig, configure_cache + + +class MyCacheBackend(CacheBackend): + def get(self, key: str): ... + def set(self, key: str, value, ttl_seconds: int) -> None: ... + def delete(self, key: str) -> None: ... + def clear(self) -> None: ... + + +configure_cache(CacheConfig(backend=MyCacheBackend())) +``` + +The backend interface has exactly four methods and no awareness of tenants, TTL policy, or domain types — all of that is handled by the SDK before keys reach your backend. + +## API Reference + +### `configure_cache(config: CacheConfig) -> None` + +Sets the global cache configuration. Must be called before any SDK client is created. + +### `get_cache_config() -> CacheConfig` + +Returns the current global cache configuration. + +### `class CacheConfig` + +Dataclass holding all cache settings. See [Configuration Reference](#configuration-reference) above. + +### `class CacheBackend` (ABC) + +Abstract base for custom backends. Implement `get`, `set`, `delete`, and `clear`. + +### `class InMemoryLRUBackend` + +The default backend. Thread-safe, LRU + TTL eviction, backed by `cachetools.TTLCache`. Suitable for single-process deployments. + +### `class IsolationStrategy` + +Enum with values `TENANT` and `TENANT_USER`. See [Isolation Strategy](#isolation-strategy) above. + +## Error Handling + +```python +from sap_cloud_sdk.cache.exceptions import BackendError, CacheError + +try: + # SDK client operations that use the cache internally + ... +except BackendError as e: + # Raised when a custom backend raises during a set() call + print(f"Cache backend error: {e}") +except CacheError as e: + print(f"Cache error: {e}") +``` + +Cache misses and backend errors during `get()` are always safe — the SDK treats them as misses and falls back to a fresh fetch. diff --git a/src/sap_cloud_sdk/core/telemetry/module.py b/src/sap_cloud_sdk/core/telemetry/module.py index 528618dd..eb97c6aa 100644 --- a/src/sap_cloud_sdk/core/telemetry/module.py +++ b/src/sap_cloud_sdk/core/telemetry/module.py @@ -13,6 +13,7 @@ class Module(str, Enum): AUDITLOG = "auditlog" AUDITLOG_NG = "auditlog_ng" BOOTSTRAP = "bootstrap" + CACHE = "cache" DATA_ANONYMIZATION = "data_anonymization" DESTINATION = "destination" DMS = "dms" diff --git a/tests/cache/__init__.py b/tests/cache/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cache/unit/__init__.py b/tests/cache/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cache/unit/test_backend.py b/tests/cache/unit/test_backend.py new file mode 100644 index 00000000..ed62b4e2 --- /dev/null +++ b/tests/cache/unit/test_backend.py @@ -0,0 +1,102 @@ +"""Unit tests for InMemoryLRUBackend.""" + +import time + +import pytest + +from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend + + +class TestInMemoryLRUBackendGet: + def test_get_missing_key_returns_none(self) -> None: + backend = InMemoryLRUBackend() + assert backend.get("nonexistent") is None + + def test_get_returns_stored_value(self) -> None: + backend = InMemoryLRUBackend() + backend.set("k", "v", ttl_seconds=60) + assert backend.get("k") == "v" + + def test_get_after_ttl_expiry_returns_none(self) -> None: + backend = InMemoryLRUBackend() + backend.set("k", "v", ttl_seconds=1) + time.sleep(1.1) + assert backend.get("k") is None + + def test_get_stores_arbitrary_value_types(self) -> None: + backend = InMemoryLRUBackend() + payload = {"a": [1, 2, 3], "b": True} + backend.set("k", payload, ttl_seconds=60) + assert backend.get("k") == payload + + +class TestInMemoryLRUBackendSet: + def test_set_overwrites_existing_key(self) -> None: + backend = InMemoryLRUBackend() + backend.set("k", "first", ttl_seconds=60) + backend.set("k", "second", ttl_seconds=60) + assert backend.get("k") == "second" + + def test_set_with_zero_ttl_clamps_to_one_second(self) -> None: + backend = InMemoryLRUBackend() + # TTL of 0 is clamped to 1 inside Cache.set(); here we test the backend + # directly with ttl_seconds=1 to confirm it stores then expires. + backend.set("k", "v", ttl_seconds=1) + assert backend.get("k") == "v" + + +class TestInMemoryLRUBackendDelete: + def test_delete_removes_existing_entry(self) -> None: + backend = InMemoryLRUBackend() + backend.set("k", "v", ttl_seconds=60) + backend.delete("k") + assert backend.get("k") is None + + def test_delete_nonexistent_key_is_noop(self) -> None: + backend = InMemoryLRUBackend() + backend.delete("missing") # must not raise + + def test_delete_fires_on_evict_callback_with_manual_reason(self) -> None: + evictions: list[tuple[str, str]] = [] + backend = InMemoryLRUBackend(on_evict=lambda k, r: evictions.append((k, r))) + backend.set("k", "v", ttl_seconds=60) + backend.delete("k") + assert evictions == [("k", "manual")] + + +class TestInMemoryLRUBackendClear: + def test_clear_removes_all_entries(self) -> None: + backend = InMemoryLRUBackend() + backend.set("a", 1, ttl_seconds=60) + backend.set("b", 2, ttl_seconds=60) + backend.clear() + assert backend.get("a") is None + assert backend.get("b") is None + + def test_clear_on_empty_backend_is_noop(self) -> None: + backend = InMemoryLRUBackend() + backend.clear() # must not raise + + +class TestInMemoryLRUBackendLRUEviction: + def test_lru_eviction_when_max_size_exceeded(self) -> None: + backend = InMemoryLRUBackend(max_size=2) + backend.set("a", 1, ttl_seconds=60) + backend.set("b", 2, ttl_seconds=60) + # access "a" to make "b" the LRU + backend.get("a") + # adding "c" should evict "b" (LRU) + backend.set("c", 3, ttl_seconds=60) + assert backend.get("a") is not None + assert backend.get("c") is not None + assert backend.get("b") is None + + def test_lru_eviction_fires_on_evict_callback(self) -> None: + evictions: list[tuple[str, str]] = [] + backend = InMemoryLRUBackend( + max_size=1, + on_evict=lambda k, r: evictions.append((k, r)), + ) + backend.set("first", "v", ttl_seconds=60) + backend.set("second", "v", ttl_seconds=60) + assert any(reason == "lru" for _, reason in evictions) diff --git a/tests/cache/unit/test_cache.py b/tests/cache/unit/test_cache.py new file mode 100644 index 00000000..3cb578a3 --- /dev/null +++ b/tests/cache/unit/test_cache.py @@ -0,0 +1,156 @@ +"""Unit tests for the Cache façade.""" + +from unittest.mock import MagicMock, call + +import pytest + +from sap_cloud_sdk.cache._backend import CacheBackend +from sap_cloud_sdk.cache._cache import Cache +from sap_cloud_sdk.cache._config import CacheConfig, configure_cache +from sap_cloud_sdk.cache._isolation import IsolationStrategy +from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend +from sap_cloud_sdk.cache.exceptions import BackendError + + +class TestCacheGet: + def test_get_miss_returns_none(self) -> None: + cache = Cache(CacheConfig()) + result = cache.get("ns", "key", tenant_id="t1") + assert result is None + + def test_get_returns_stored_value(self) -> None: + cache = Cache(CacheConfig()) + cache.set("ns", "key", "value", ttl_seconds=60, tenant_id="t1") + assert cache.get("ns", "key", tenant_id="t1") == "value" + + def test_get_disabled_always_returns_none(self) -> None: + cache = Cache(CacheConfig(enabled=False)) + cache.set("ns", "key", "value", ttl_seconds=60, tenant_id="t1") + assert cache.get("ns", "key", tenant_id="t1") is None + + def test_get_different_tenants_are_isolated(self) -> None: + cache = Cache(CacheConfig()) + cache.set("ns", "key", "t1-value", ttl_seconds=60, tenant_id="t1") + assert cache.get("ns", "key", tenant_id="t2") is None + + def test_get_different_users_are_isolated(self) -> None: + cache = Cache(CacheConfig()) + cache.set("ns", "key", "u1-value", ttl_seconds=60, tenant_id="t", user_id="u1") + assert cache.get("ns", "key", tenant_id="t", user_id="u2") is None + + def test_get_swallows_backend_exceptions_and_returns_none(self) -> None: + bad_backend = MagicMock(spec=CacheBackend) + bad_backend.get.side_effect = RuntimeError("backend down") + cache = Cache(CacheConfig(backend=bad_backend)) + assert cache.get("ns", "key", tenant_id="t") is None + + +class TestCacheSet: + def test_set_disabled_is_noop(self) -> None: + backend = MagicMock(spec=CacheBackend) + cache = Cache(CacheConfig(enabled=False, backend=backend)) + cache.set("ns", "key", "v", ttl_seconds=60, tenant_id="t") + backend.set.assert_not_called() + + def test_set_applies_expiry_buffer(self) -> None: + backend = MagicMock(spec=CacheBackend) + backend.get.return_value = None + cache = Cache(CacheConfig(expiry_buffer_seconds=10, backend=backend)) + cache.set("ns", "key", "v", ttl_seconds=60, tenant_id="t") + _, _, forwarded_ttl = backend.set.call_args[0] + assert forwarded_ttl == 50 # 60 - 10 + + def test_set_uses_default_ttl_when_none_given(self) -> None: + backend = MagicMock(spec=CacheBackend) + backend.get.return_value = None + cache = Cache( + CacheConfig(default_ttl_seconds=300, expiry_buffer_seconds=30, backend=backend) + ) + cache.set("ns", "key", "v", ttl_seconds=None, tenant_id="t") + _, _, forwarded_ttl = backend.set.call_args[0] + assert forwarded_ttl == 270 # 300 - 30 + + def test_set_clamps_negative_effective_ttl_to_one(self) -> None: + backend = MagicMock(spec=CacheBackend) + backend.get.return_value = None + cache = Cache(CacheConfig(expiry_buffer_seconds=100, backend=backend)) + cache.set("ns", "key", "v", ttl_seconds=50, tenant_id="t") + _, _, forwarded_ttl = backend.set.call_args[0] + assert forwarded_ttl == 1 + + def test_set_raises_backend_error_on_backend_exception(self) -> None: + bad_backend = MagicMock(spec=CacheBackend) + bad_backend.set.side_effect = IOError("redis unavailable") + cache = Cache(CacheConfig(backend=bad_backend)) + with pytest.raises(BackendError): + cache.set("ns", "key", "v", ttl_seconds=60, tenant_id="t") + + +class TestCacheEvict: + def test_evict_removes_entry(self) -> None: + cache = Cache(CacheConfig()) + cache.set("ns", "key", "v", ttl_seconds=60, tenant_id="t") + cache.evict("ns", "key", tenant_id="t") + assert cache.get("ns", "key", tenant_id="t") is None + + def test_evict_disabled_is_noop(self) -> None: + backend = MagicMock(spec=CacheBackend) + cache = Cache(CacheConfig(enabled=False, backend=backend)) + cache.evict("ns", "key", tenant_id="t") + backend.delete.assert_not_called() + + def test_evict_swallows_backend_exceptions(self) -> None: + bad_backend = MagicMock(spec=CacheBackend) + bad_backend.delete.side_effect = RuntimeError("backend down") + cache = Cache(CacheConfig(backend=bad_backend)) + cache.evict("ns", "key", tenant_id="t") # must not raise + + +class TestCacheReset: + def test_reset_clears_all_entries(self) -> None: + cache = Cache(CacheConfig()) + cache.set("ns", "a", 1, ttl_seconds=60, tenant_id="t") + cache.set("ns", "b", 2, ttl_seconds=60, tenant_id="t") + cache.reset() + assert cache.get("ns", "a", tenant_id="t") is None + assert cache.get("ns", "b", tenant_id="t") is None + + def test_reset_swallows_backend_exceptions(self) -> None: + bad_backend = MagicMock(spec=CacheBackend) + bad_backend.clear.side_effect = RuntimeError("backend down") + cache = Cache(CacheConfig(backend=bad_backend)) + cache.reset() # must not raise + + +class TestCachePerClientConfigOverride: + def setup_method(self) -> None: + configure_cache(CacheConfig(default_ttl_seconds=300)) + + def test_per_client_config_overrides_global(self) -> None: + backend = MagicMock(spec=CacheBackend) + backend.get.return_value = None + per_client = CacheConfig(default_ttl_seconds=60, expiry_buffer_seconds=0, backend=backend) + cache = Cache(per_client) + cache.set("ns", "key", "v", ttl_seconds=None, tenant_id="t") + _, _, forwarded_ttl = backend.set.call_args[0] + assert forwarded_ttl == 60 # uses per-client default, not global 300 + + def teardown_method(self) -> None: + configure_cache(CacheConfig()) + + +class TestCacheFullKeyStructure: + def test_full_key_includes_namespace_and_key(self) -> None: + backend = MagicMock(spec=CacheBackend) + backend.get.return_value = None + cache = Cache( + CacheConfig( + isolation_strategy=IsolationStrategy.TENANT, + backend=backend, + ) + ) + cache.set("destination", "my-dest", "v", ttl_seconds=60, tenant_id="tenant-xyz") + full_key = backend.set.call_args[0][0] + assert full_key.startswith("destination::") + assert full_key.endswith("::my-dest") + assert "tenant-xyz" in full_key diff --git a/tests/cache/unit/test_config.py b/tests/cache/unit/test_config.py new file mode 100644 index 00000000..ada788c5 --- /dev/null +++ b/tests/cache/unit/test_config.py @@ -0,0 +1,59 @@ +"""Unit tests for CacheConfig, configure_cache, and get_cache_config.""" + +import pytest + +from sap_cloud_sdk.cache._config import CacheConfig, configure_cache, get_cache_config +from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend + + +class TestGetCacheConfigDefaults: + def test_get_cache_config_returns_cacheconfig_instance(self) -> None: + cfg = get_cache_config() + assert isinstance(cfg, CacheConfig) + + def test_default_config_is_enabled(self) -> None: + cfg = CacheConfig() + assert cfg.enabled is True + + def test_default_ttl_seconds(self) -> None: + assert CacheConfig().default_ttl_seconds == 300 + + def test_default_expiry_buffer_seconds(self) -> None: + assert CacheConfig().expiry_buffer_seconds == 30 + + def test_default_max_size(self) -> None: + assert CacheConfig().max_size == 1000 + + def test_default_backend_is_none(self) -> None: + assert CacheConfig().backend is None + + def test_default_isolation_strategy_is_none(self) -> None: + assert CacheConfig().isolation_strategy is None + + +class TestConfigureCache: + def setup_method(self) -> None: + # Reset global config to defaults before each test. + configure_cache(CacheConfig()) + + def test_configure_cache_replaces_global_config(self) -> None: + new_cfg = CacheConfig(default_ttl_seconds=999) + configure_cache(new_cfg) + assert get_cache_config().default_ttl_seconds == 999 + + def test_configure_cache_with_custom_backend(self) -> None: + backend = InMemoryLRUBackend(max_size=50) + configure_cache(CacheConfig(backend=backend)) + assert get_cache_config().backend is backend + + def test_configure_cache_disabled(self) -> None: + configure_cache(CacheConfig(enabled=False)) + assert get_cache_config().enabled is False + + def test_configure_cache_multiple_times_uses_last(self) -> None: + configure_cache(CacheConfig(default_ttl_seconds=100)) + configure_cache(CacheConfig(default_ttl_seconds=200)) + assert get_cache_config().default_ttl_seconds == 200 + + def teardown_method(self) -> None: + configure_cache(CacheConfig()) diff --git a/tests/cache/unit/test_isolation.py b/tests/cache/unit/test_isolation.py new file mode 100644 index 00000000..2188f922 --- /dev/null +++ b/tests/cache/unit/test_isolation.py @@ -0,0 +1,81 @@ +"""Unit tests for IsolationStrategy and build_isolation_key.""" + +import hashlib + +import pytest + +from sap_cloud_sdk.cache._isolation import IsolationStrategy, build_isolation_key + + +class TestBuildIsolationKeyAutoSelect: + def test_no_user_id_returns_tenant_id(self) -> None: + key = build_isolation_key(tenant_id="tenant-abc") + assert key == "tenant-abc" + + def test_empty_user_id_returns_tenant_id(self) -> None: + key = build_isolation_key(tenant_id="tenant-abc", user_id="") + assert key == "tenant-abc" + + def test_with_user_id_returns_sha256_hash(self) -> None: + key = build_isolation_key(tenant_id="tenant-abc", user_id="user-123") + expected = hashlib.sha256(b"tenant-abc|user-123").hexdigest()[:32] + assert key == expected + + def test_hash_is_32_chars(self) -> None: + key = build_isolation_key(tenant_id="t", user_id="u") + assert len(key) == 32 + + def test_different_users_produce_different_keys(self) -> None: + k1 = build_isolation_key(tenant_id="t", user_id="user-1") + k2 = build_isolation_key(tenant_id="t", user_id="user-2") + assert k1 != k2 + + def test_different_tenants_produce_different_keys(self) -> None: + k1 = build_isolation_key(tenant_id="tenant-1", user_id="u") + k2 = build_isolation_key(tenant_id="tenant-2", user_id="u") + assert k1 != k2 + + def test_same_inputs_produce_stable_hash(self) -> None: + k1 = build_isolation_key(tenant_id="t", user_id="u") + k2 = build_isolation_key(tenant_id="t", user_id="u") + assert k1 == k2 + + +class TestBuildIsolationKeyExplicitStrategy: + def test_explicit_tenant_strategy_ignores_user_id(self) -> None: + key = build_isolation_key( + tenant_id="tenant-abc", + user_id="user-123", + strategy=IsolationStrategy.TENANT, + ) + assert key == "tenant-abc" + + def test_explicit_tenant_user_strategy_with_user_id(self) -> None: + key = build_isolation_key( + tenant_id="t", + user_id="u", + strategy=IsolationStrategy.TENANT_USER, + ) + expected = hashlib.sha256(b"t|u").hexdigest()[:32] + assert key == expected + + def test_explicit_tenant_user_strategy_without_user_id_falls_back_to_tenant( + self, + ) -> None: + key = build_isolation_key( + tenant_id="tenant-abc", + user_id=None, + strategy=IsolationStrategy.TENANT_USER, + ) + assert key == "tenant-abc" + + def test_downgrade_warning_is_logged(self, caplog: pytest.LogCaptureFixture) -> None: + import logging + + with caplog.at_level(logging.WARNING, logger="sap_cloud_sdk.cache._isolation"): + build_isolation_key( + tenant_id="t", + user_id="u", + strategy=IsolationStrategy.TENANT, + ) + assert any("cross-user" in record.message for record in caplog.records) diff --git a/uv.lock b/uv.lock index f4d26248..19f358f7 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -284,6 +284,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "cachetools" +version = "5.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, +] + [[package]] name = "cel-python" version = "0.5.0" @@ -615,8 +624,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +674,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version < '3.12'" }, - { name = "sqlparse", marker = "python_full_version < '3.12'" }, - { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +694,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version >= '3.12'" }, - { name = "sqlparse", marker = "python_full_version >= '3.12'" }, - { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -1013,9 +1022,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", size = 623515, upload-time = "2026-05-20T14:09:07.853Z" }, { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", size = 418439, upload-time = "2026-05-20T14:01:38.446Z" }, { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, @@ -1023,9 +1030,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, - { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -1033,9 +1038,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -1043,9 +1046,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -1053,18 +1054,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, - { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, - { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, - { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -1072,9 +1069,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, - { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, @@ -3942,6 +3937,7 @@ name = "sap-cloud-sdk" version = "0.41.0" source = { editable = "." } dependencies = [ + { name = "cachetools" }, { name = "cryptography" }, { name = "grpcio" }, { name = "hatchling" }, @@ -4031,6 +4027,7 @@ dev = [ requires-dist = [ { name = "a2a-sdk", marker = "extra == 'extensibility'", specifier = ">=0.2.0" }, { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.9.0" }, + { name = "cachetools", specifier = "~=5.5.2" }, { name = "cryptography", specifier = ">=46.0.3" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.0" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0" }, From 1727ebf0e3a8284a4480755317179d161c5e78fd Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Mon, 10 Aug 2026 15:09:25 -0300 Subject: [PATCH 2/2] feat(cache): enhance caching module with backend type hinting and multi-tenancy support --- src/sap_cloud_sdk/cache/_cache.py | 3 ++- src/sap_cloud_sdk/cache/_lru_backend.py | 5 +---- src/sap_cloud_sdk/cache/user-guide.md | 21 ++++++++++++++++----- tests/core/unit/telemetry/test_module.py | 3 ++- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/sap_cloud_sdk/cache/_cache.py b/src/sap_cloud_sdk/cache/_cache.py index 4e4b12a9..de171f46 100644 --- a/src/sap_cloud_sdk/cache/_cache.py +++ b/src/sap_cloud_sdk/cache/_cache.py @@ -19,6 +19,7 @@ import logging from typing import Any +from sap_cloud_sdk.cache._backend import CacheBackend from sap_cloud_sdk.cache._config import CacheConfig, get_cache_config from sap_cloud_sdk.cache._isolation import build_isolation_key from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend @@ -151,7 +152,7 @@ def reset(self) -> None: # Private helpers # ------------------------------------------------------------------ - def _resolve_backend(self) -> Any: + def _resolve_backend(self) -> CacheBackend: if self._config.backend is not None: return self._config.backend return InMemoryLRUBackend( diff --git a/src/sap_cloud_sdk/cache/_lru_backend.py b/src/sap_cloud_sdk/cache/_lru_backend.py index f643d28e..aeeec918 100644 --- a/src/sap_cloud_sdk/cache/_lru_backend.py +++ b/src/sap_cloud_sdk/cache/_lru_backend.py @@ -4,6 +4,7 @@ import logging import threading +import time from typing import Any, Callable from cachetools import TTLCache @@ -75,8 +76,6 @@ def __init__( self._lock = threading.Lock() def get(self, key: str) -> Any | None: - import time - with self._lock: entry = self._cache.get(key) if entry is None: @@ -91,8 +90,6 @@ def get(self, key: str) -> Any | None: return value def set(self, key: str, value: Any, ttl_seconds: int) -> None: - import time - expires_at = time.monotonic() + max(ttl_seconds, 1) with self._lock: self._cache[key] = (value, expires_at) diff --git a/src/sap_cloud_sdk/cache/user-guide.md b/src/sap_cloud_sdk/cache/user-guide.md index 6ca15c11..307cbfaf 100644 --- a/src/sap_cloud_sdk/cache/user-guide.md +++ b/src/sap_cloud_sdk/cache/user-guide.md @@ -17,11 +17,13 @@ By default every SDK client uses an in-process LRU cache with sensible defaults. ```python from sap_cloud_sdk.cache import CacheConfig, configure_cache -configure_cache(CacheConfig( - default_ttl_seconds=600, - expiry_buffer_seconds=60, - max_size=2000, -)) +configure_cache( + CacheConfig( + default_ttl_seconds=600, + expiry_buffer_seconds=60, + max_size=2000, + ) +) ``` Call `configure_cache()` **once at startup**, before any SDK client is created. Changes made after client construction have no effect on already-instantiated clients. @@ -76,9 +78,11 @@ from sap_cloud_sdk.cache import CacheConfig, configure_cache logger = logging.getLogger(__name__) + def on_evict(key: str, reason: str) -> None: logger.info("cache evicted key=%s reason=%s", key, reason) + configure_cache(CacheConfig(on_evict=on_evict)) ``` @@ -132,6 +136,13 @@ The default backend. Thread-safe, LRU + TTL eviction, backed by `cachetools.TTLC Enum with values `TENANT` and `TENANT_USER`. See [Isolation Strategy](#isolation-strategy) above. +## Multi-tenancy + +- **Supported:** Yes, `TENANT` and `TENANT_USER` isolation strategies +- **Authentication:** N/A, the cache module does not perform BTP authentication +- **How to use:** Set `isolation_strategy` in `CacheConfig`. Auto-selection uses `TENANT_USER` when a `user_id` is provided to `Cache.get()`/`Cache.set()`, otherwise `TENANT` +- **Further reading:** N/A + ## Error Handling ```python diff --git a/tests/core/unit/telemetry/test_module.py b/tests/core/unit/telemetry/test_module.py index f1a325e0..d15f10c6 100644 --- a/tests/core/unit/telemetry/test_module.py +++ b/tests/core/unit/telemetry/test_module.py @@ -55,7 +55,7 @@ def test_module_in_collection(self): def test_all_modules_present(self): """Test that all expected modules are present.""" all_modules = list(Module) - assert len(all_modules) == 15 + assert len(all_modules) == 16 assert Module.ADMS in all_modules assert Module.AGENT_MEMORY in all_modules assert Module.AGENTGATEWAY in all_modules @@ -63,6 +63,7 @@ def test_all_modules_present(self): assert Module.AUDITLOG in all_modules assert Module.AUDITLOG_NG in all_modules assert Module.BOOTSTRAP in all_modules + assert Module.CACHE in all_modules assert Module.DATA_ANONYMIZATION in all_modules assert Module.DESTINATION in all_modules assert Module.DMS in all_modules