Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions docs/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,34 @@ See the [Rust guide](languages/rust.md).

<LanguageContent language="python">

The `cached` decorator takes `key_type`, `use_case`, and either a `cache_key`
callback or `id_arg`. `id_arg=(name, adapter)` extracts an ID from a native object;
other bound arguments use `arg_adapters` and `ignore_args`. Inline calls accept
a structured `Key` or an ID plus normalized argument mapping. See the
[Python guide](languages/python.md#identity-and-policy).
For `@cache.cached(...)`, prefer `cache_key`: it receives the loader's arguments
and returns a bare ID or `{"id": ..., "args": {...}}`. `get_or_load()` accepts
the same shape directly as `key`. This API excerpt assumes an application `db`:

```python
from dialcache import DialCache, Policy

cache = DialCache(namespace="users-api")


@cache.cached(
key_type="user_id",
use_case="GetUser",
cache_key=lambda user_id, locale: {"id": user_id, "args": {"locale": locale}},
default_config=Policy(ttl_sec={"local": 60}),
)
async def get_user(user_id: str, locale: str):
return await db.fetch_user(user_id, locale)


async with cache.enable():
await get_user("123", "en")
```

The selector must accept the loader's positional and keyword call forms; loader
defaults are not automatically supplied to it. Inferred identity through
`id_arg`, `arg_adapters`, and `ignore_args` remains supported. See the
[Python guide](languages/python.md#identity-and-policy) for these binding details.

</LanguageContent>

Expand Down
42 changes: 35 additions & 7 deletions docs/languages/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,41 @@ the public API.

## Identity and policy

Specify `cache_key=` for explicit identity selection or `id_arg=` for a named
function argument. An `id_arg=(name, adapter)` pair converts a native object
into a primitive entity ID. Other bound arguments, including default values,
participate in identity; `arg_adapters` converts them and `ignore_args` excludes
inputs that do not affect the result. The default use-case name is the source
function's module and qualified name. Explicit names provide stability across
refactoring.
Prefer `cache_key=` to select identity explicitly, as shown in the shared
[keys guide](../keys.md#define-a-result-identity). Return a bare entity ID or
`{"id": ..., "args": {...}}`; a structured `Key` is also accepted. Choose a
stable `use_case` and argument names instead of tying cache identity to Python
function names. Include every input that affects the returned value.

The selector receives the invocation's original positional and keyword
arguments, including `self` for a decorated method. Match the loader's parameter
names and defaults: a loader default such as `locale="en"` must also appear on
its selector if callers may omit it. DialCache does not fill in loader defaults
before invoking `cache_key`. Select any instance state that affects the result,
such as `self.tenant_id`, explicitly. A selector failure bypasses caching for
that invocation.

Use `CacheKeySpec` to annotate a named selector. For a loader that also defaults
`locale` to `"en"`, pass this function as `cache_key=user_key`:

```python
from dialcache import CacheKeySpec


def user_key(user_id: str, locale: str = "en") -> CacheKeySpec:
return {"id": user_id, "args": {"locale": locale}}
```

The decorated function preserves the loader's parameter types and returns an
awaitable of its result type, including for synchronous loaders.

`id_arg=` remains a supported alternative for inferred identity. An
`id_arg=(name, adapter)` pair converts a native object into a primitive entity
ID. Other bound arguments, including default values, participate in identity;
`arg_adapters` converts them and `ignore_args` excludes inputs that do not affect
the result. Inferred method arguments omit `self` unless explicitly adapted.
The default use-case name is the source function's module and qualified name;
explicit names remain stable across refactoring.

`Policy` uses snake_case fields such as `ttl_sec`, `request_local`, and
`remote_read_timeout_ms`. Static settings are captured when registering the
Expand Down
2 changes: 1 addition & 1 deletion formal/go-parity.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"inputs": {
"semanticCasesSha256": "f3989f3032706ec6880ea29805dd1634964d868a2c3d99188adc6eaa1aac68ca",
"executionSha256": "bdb8428c4fc4b34d7eed84d60f53fce53331bc3d3f16433c720e34331a9ea64f",
"sourceAuditSha256": "27975bae3abdf8143f18d2d161982d35154cd74c597c1960404b8179e94a5ad9",
"sourceAuditSha256": "513b37458be3fac01958cfed2d5d7965d354200b0313a36571f21b816444e69f",
"featureCoverageSha256": "e177762da07f2b796a8bd9bc9ba69f6261b82cd9cee98391e97c2bfc3253d964",
"coverageWitnessesSha256": "2b2d5d57daacba3ecd3637f5a917d58b0b63fcc88893e651cd602cdba1ef4244",
"profilesSha256": "15fbdb476b5ee9f39ea017ef5668d9c26cb7d53ebc96a6b7c4f531cccfa8d897"
Expand Down
20 changes: 10 additions & 10 deletions formal/source-audit.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,15 +152,15 @@
},
{
"path": "docs/keys.md",
"sha256": "9848b0713587794f0fb7f92c7143b1fae50fc2684242c0cdeb6c97804f8fb24f",
"sha256": "09446ece068184cd0828ccb6f726a95b59d970d509a342ff3c6cff5d3d587504",
"entries": [
{"line":1,"title":"Keys and identity","contracts":["W01"]},
{"line":14,"title":"Anatomy of a key","contracts":["W01"]},
{"line":42,"title":"Define a result identity","contracts":["W01","E04"]},
{"line":120,"title":"Normalization and encoding","contracts":["W02"]},
{"line":181,"title":"Namespace","contracts":["W01"]},
{"line":188,"title":"Changing a namespace","contracts":["W01","E05"]},
{"line":205,"title":"The key passed to runtime policy","contracts":["C16","B01"]}
{"line":143,"title":"Normalization and encoding","contracts":["W02"]},
{"line":204,"title":"Namespace","contracts":["W01"]},
{"line":211,"title":"Changing a namespace","contracts":["W01","E05"]},
{"line":228,"title":"The key passed to runtime policy","contracts":["C16","B01"]}
]
},
{
Expand All @@ -178,16 +178,16 @@
},
{
"path": "docs/languages/python.md",
"sha256": "4d4e0f898e167c50299d264dcec88e73bd7fc4e47e30141556a75f00c474086b",
"sha256": "6b0625588d4028207e8069241a64a2d73e825d07f56e2d23111ee24fd4b5ad90",
"entries": [
{"line":1,"title":"Python integration","contracts":["B01"]},
{"line":10,"title":"Installation and runtime","contracts":["B01","B02"]},
{"line":29,"title":"Request scope and operations","contracts":["C01","C02","C03","B01","B02"]},
{"line":46,"title":"Identity and policy","contracts":["W01","W02","C16","C18","C20","B01"]},
{"line":69,"title":"Values and cancellation","contracts":["B03","C25","C26","E04","B02"]},
{"line":87,"title":"Redis and observability","contracts":["C29","C32","W04","E01","X01","B01"]},
{"line":106,"title":"Sharing Redis with other languages","contracts":["W01","W02","E04","B03","B01"]},
{"line":116,"title":"Validation","contracts":["B01"]}
{"line":97,"title":"Values and cancellation","contracts":["B03","C25","C26","E04","B02"]},
{"line":115,"title":"Redis and observability","contracts":["C29","C32","W04","E01","X01","B01"]},
{"line":134,"title":"Sharing Redis with other languages","contracts":["W01","W02","E04","B03","B01"]},
{"line":144,"title":"Validation","contracts":["B01"]}
]
},
{
Expand Down
15 changes: 13 additions & 2 deletions python/API-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,22 @@ The Python binding was designed after reviewing [rungalileo/gcache at a688049](h

DialCache's [Quint specification](../formal/SPEC.md) and TypeScript implementation define portable behavior. Gcache supplies useful Python interface ideas; its implementation is not a compatible DialCache backend.

The primary decorator style selects identity explicitly with
`cache_key=lambda user_id, locale: {"id": user_id, "args": {"locale": locale}}`
and a stable `use_case`. This follows TypeScript's `cacheKey` while retaining
Python decorator syntax. The selector makes result dimensions and their wire
names explicit; it must include every input that affects the result.

Selectors receive the loader's original `*args` and `**kwargs`. Match parameter
names and defaults so positional, keyword, and omitted-default calls select
the intended identity. Loader defaults are not injected into the selector;
changing that behavior could change existing keys or break existing callbacks.

| Gcache interface or behavior | Python DialCache decision |
| --- | --- |
| `with cache.enable(enabled=True)` | Retained, with per-instance context variables and explicit outer-scope lifetime. Also supports `async with` and `disable()`. |
| `@cache.cached(key_type=..., id_arg=...)` | Retained. `id_arg` can be a parameter name or `(name, adapter)` pair. Signature binding includes defaults. |
| `arg_adapters`, `ignore_args`, inferred use case | Retained; inferred names include module and qualified function name. Argument names use the portable UTF-16 ordering and scalar normalization. |
| `@cache.cached(key_type=..., id_arg=...)` | Supported alternative to explicit `cache_key` selection. `id_arg` can be a parameter name or `(name, adapter)` pair. Signature binding includes defaults. |
| `arg_adapters`, `ignore_args`, inferred use case | Inferred argument controls remain supported. Default use-case names include module and qualified function name; prefer explicit names for stability across refactors and languages. Argument names use the portable UTF-16 ordering and scalar normalization. |
| Direct `aget(key, fallback)` | Retained as a structured-key convenience; `get_or_load` is the primary inline-loader API. |
| `ainvalidate` | Retained as an alias for `invalidate_remote`; missing Redis and failed mutations raise. |
| `GCacheKeyConfig` and per-use-case provider | `Policy` / `DialCacheKeyConfig` use sparse per-leaf inheritance and deterministic per-key cohorts. `Policy.enabled(ttl_sec)` is available. |
Expand Down
30 changes: 23 additions & 7 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ any Redis client connections.

## A cached function

Select cache identity explicitly with `cache_key`. This API excerpt assumes an
application `database`:

```python
from dialcache import DialCache, Policy

Expand All @@ -32,20 +35,33 @@ cache = DialCache(namespace="my-service")
@cache.cached(
use_case="user-profile",
key_type="user",
id_arg="user_id",
cache_key=lambda user_id, locale: {"id": user_id, "args": {"locale": locale}},
default_config=Policy(ttl_sec={"local": 5}, request_local=True),
)
async def get_profile(user_id: str) -> dict:
return await database.fetch_profile(user_id)
async def get_profile(user_id: str, locale: str) -> dict:
return await database.fetch_profile(user_id, locale)


async def handle_request(user_id: str) -> dict:
async def handle_request(user_id: str, locale: str) -> dict:
async with cache.enable():
first = await get_profile(user_id)
second = await get_profile(user_id) # Same request memo.
first = await get_profile(user_id, locale)
second = await get_profile(user_id, locale) # Same request memo.
return second
```

The selector returns an entity ID, or `{"id": ..., "args": {...}}` when the
result has additional dimensions. Here, `id` groups the user's tracked values
for invalidation; `args` distinguishes locales within this use case. Include
every input that affects the result, including tenant or authorization scope
when relevant. Explicit `use_case` and argument names keep identity stable
across function refactors and languages.

The selector receives the same positional and keyword arguments as the loader.
Match their parameter names and repeat any defaults the loader accepts; loader
defaults are not filled in before calling the selector. For inferred identity,
`id_arg`, `arg_adapters`, and `ignore_args` remain supported alternatives; see
the [Python identity guide](https://lan17.github.io/DialCache/languages/python.html#identity-and-policy).

Calls outside `enable()` go directly to the source. They do not construct cache
keys, resolve policy, share concurrent work, or apply a DialCache source
deadline. Both `with cache.enable():` and `async with cache.enable():` are valid;
Expand Down Expand Up @@ -130,7 +146,7 @@ cache = DialCache(redis=RedisAdapter(client))
@cache.cached(
use_case="user-profile",
key_type="user",
id_arg="user_id",
cache_key=lambda user_id: user_id,
track_for_invalidation=True,
default_config=Policy(ttl_sec={"remote": 60}),
)
Expand Down
4 changes: 3 additions & 1 deletion python/dialcache/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
UseCaseIsAlreadyRegisteredError,
UseCaseNameIsReservedError,
)
from .key import Key, normalize_args
from .key import CacheKeySpec, Key, KeyScalar, normalize_args
from .serializer import UNDEFINED, JsonSerializer, Serializer

__all__ = [
Expand All @@ -22,6 +22,8 @@
"DialCacheKeyConfig",
"CacheLayer",
"Key",
"CacheKeySpec",
"KeyScalar",
"normalize_args",
"Serializer",
"JsonSerializer",
Expand Down
31 changes: 24 additions & 7 deletions python/dialcache/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
import json
import logging
import math
from collections.abc import Awaitable, Callable, Mapping
from collections.abc import Awaitable, Callable, Coroutine, Mapping
from dataclasses import dataclass, field
from typing import Any, ParamSpec, TypeVar
from typing import Any, ParamSpec, Protocol, TypeVar, overload

from .clock import SystemClock
from .config import UNSET, Policy, _valid_ramp, merge_policy, resolve_layer, validate_static_policy
Expand All @@ -29,7 +29,7 @@
UseCaseIsAlreadyRegisteredError,
UseCaseNameIsReservedError,
)
from .key import Key, invalidation_prefix, normalize_args, ramp_sample
from .key import Key, KeyScalar, invalidation_prefix, normalize_args, ramp_sample
from .local import LocalCache
from .metrics import Metrics, emit_metric
from .protocol import Frame, Miss, compress_payload, decompress_payload, escape_raw_payload, utf8_bytes
Expand All @@ -41,6 +41,14 @@
MAX_SAFE = 9_007_199_254_740_991


class _CachedDecorator(Protocol):
@overload
def __call__(self, fn: Callable[P, Awaitable[T]], /) -> Callable[P, Coroutine[Any, Any, T]]: ...

@overload
def __call__(self, fn: Callable[P, T | Awaitable[T]], /) -> Callable[P, Coroutine[Any, Any, T]]: ...


async def _await(value: Any) -> Any:
return await value if inspect.isawaitable(value) else value

Expand Down Expand Up @@ -243,22 +251,31 @@ def cached(
self,
*,
key_type: str,
cache_key: Callable[..., Any] | None = None,
cache_key: Callable[..., Mapping[str, object] | KeyScalar | Key] | None = None,
id_arg: str | tuple[str, Callable[[Any], Any]] | None = None,
use_case: str | None = None,
arg_adapters: Mapping[str, Callable[[Any], Any]] | None = None,
ignore_args: list[str] | tuple[str, ...] = (),
**options: Any,
) -> Callable[..., Any]:
"""Decorate a loader using an explicit selector or gcache-style arguments.
) -> _CachedDecorator:
"""Decorate a loader with explicit cache_key selection (recommended) or id_arg.

The wrapper is always awaitable, including for a synchronous loader.
Key callbacks and argument adapters are never called while disabled.
An explicit selector receives the original positional and keyword
arguments, including self for methods. Loader defaults are not injected:
declare matching defaults on the selector when callers can omit them.
"""
if (cache_key is None) == (id_arg is None):
raise ConfigError("Supply exactly one of cache_key or id_arg")

def decorate(fn: Callable[P, T | Awaitable[T]]) -> Callable[P, Awaitable[T]]:
@overload
def decorate(fn: Callable[P, Awaitable[T]]) -> Callable[P, Coroutine[Any, Any, T]]: ...

@overload
def decorate(fn: Callable[P, T | Awaitable[T]]) -> Callable[P, Coroutine[Any, Any, T]]: ...

def decorate(fn: Callable[P, T | Awaitable[T]]) -> Callable[P, Coroutine[Any, Any, T]]:
name = use_case or f"{fn.__module__}.{fn.__qualname__}"
self._check_use_case(name)
if name in self._registered:
Expand Down
12 changes: 11 additions & 1 deletion python/dialcache/key.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,19 @@
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import NotRequired, TypeAlias, TypedDict
from urllib.parse import quote

from .serializer import UNDEFINED
from .serializer import UNDEFINED, _Undefined

KeyScalar: TypeAlias = str | int | float | bool | None


class CacheKeySpec(TypedDict):
"""An entity id and the explicit result dimensions selected for a cache key."""

id: KeyScalar
args: NotRequired[Mapping[str, KeyScalar | _Undefined]]


def _integer_string(value: int) -> str:
Expand Down
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ classifiers = [

[project.optional-dependencies]
redis = ["redis>=5,<7"]
test = ["pytest>=8,<10", "pytest-asyncio>=0.24,<2", "jsonschema>=4.23,<5", "coverage>=7.11,<8"]
test = ["pytest>=8,<10", "pytest-asyncio>=0.24,<2", "jsonschema>=4.23,<5", "coverage>=7.11,<8", "mypy>=1.15,<2"]

[project.urls]
Repository = "https://github.com/lan17/DialCache"
Expand Down
Loading
Loading