dialcache 0.25.0__tar.gz → 0.26.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.
- {dialcache-0.25.0 → dialcache-0.26.0}/API-DESIGN.md +13 -2
- {dialcache-0.25.0 → dialcache-0.26.0}/PKG-INFO +25 -8
- {dialcache-0.25.0 → dialcache-0.26.0}/README.md +23 -7
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/__init__.py +3 -1
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/cache.py +24 -7
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/key.py +11 -1
- {dialcache-0.25.0 → dialcache-0.26.0}/pyproject.toml +2 -2
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/run_integration.py +11 -2
- dialcache-0.26.0/tests/test_cached_keys.py +123 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_docs_examples.py +34 -3
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_integration_acceptance.py +45 -20
- dialcache-0.26.0/tests/test_typing.py +33 -0
- dialcache-0.26.0/tests/test_wire_suite.py +38 -0
- dialcache-0.26.0/tests/typing/cached.py +115 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/.gitignore +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/LICENSE +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/clock.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/config.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/context.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/errors.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/local.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/metrics.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/protocol.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/py.typed +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/redis.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/serializer.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/__init__.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/coordinator.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/driver.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/executor.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/scenarios.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/schema.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/simple_drivers.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/witness.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/run_conformance.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_cache.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_callback_cancellation.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_cluster_read_routing.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_config.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_conformance.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_context.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_dependency_cancellation.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_engine_review.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_local.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_observer_recovery_boundaries.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_protocol_native.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_protocol_vectors.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_redis_adapter.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_redis_integration.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_review_regressions.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_shadow_deadline_retention.py +0 -0
- {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_validation_environment.py +0 -0
|
@@ -4,11 +4,22 @@ The Python binding was designed after reviewing [rungalileo/gcache at a688049](h
|
|
|
4
4
|
|
|
5
5
|
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.
|
|
6
6
|
|
|
7
|
+
The primary decorator style selects identity explicitly with
|
|
8
|
+
`cache_key=lambda user_id, locale: {"id": user_id, "args": {"locale": locale}}`
|
|
9
|
+
and a stable `use_case`. This follows TypeScript's `cacheKey` while retaining
|
|
10
|
+
Python decorator syntax. The selector makes result dimensions and their wire
|
|
11
|
+
names explicit; it must include every input that affects the result.
|
|
12
|
+
|
|
13
|
+
Selectors receive the loader's original `*args` and `**kwargs`. Match parameter
|
|
14
|
+
names and defaults so positional, keyword, and omitted-default calls select
|
|
15
|
+
the intended identity. Loader defaults are not injected into the selector;
|
|
16
|
+
changing that behavior could change existing keys or break existing callbacks.
|
|
17
|
+
|
|
7
18
|
| Gcache interface or behavior | Python DialCache decision |
|
|
8
19
|
| --- | --- |
|
|
9
20
|
| `with cache.enable(enabled=True)` | Retained, with per-instance context variables and explicit outer-scope lifetime. Also supports `async with` and `disable()`. |
|
|
10
|
-
| `@cache.cached(key_type=..., id_arg=...)` |
|
|
11
|
-
| `arg_adapters`, `ignore_args`, inferred use case |
|
|
21
|
+
| `@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. |
|
|
22
|
+
| `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. |
|
|
12
23
|
| Direct `aget(key, fallback)` | Retained as a structured-key convenience; `get_or_load` is the primary inline-loader API. |
|
|
13
24
|
| `ainvalidate` | Retained as an alias for `invalidate_remote`; missing Redis and failed mutations raise. |
|
|
14
25
|
| `GCacheKeyConfig` and per-use-case provider | `Policy` / `DialCacheKeyConfig` use sparse per-leaf inheritance and deterministic per-key cohorts. `Policy.enabled(ttl_sec)` is available. |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: dialcache
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.26.0
|
|
4
4
|
Summary: Explicitly enabled async caching with runtime policies, coalescing, and tracked Redis invalidation.
|
|
5
5
|
Project-URL: Repository, https://github.com/lan17/DialCache
|
|
6
6
|
Project-URL: Documentation, https://github.com/lan17/DialCache/tree/main/python
|
|
@@ -21,6 +21,7 @@ Requires-Dist: redis<7,>=5; extra == 'redis'
|
|
|
21
21
|
Provides-Extra: test
|
|
22
22
|
Requires-Dist: coverage<8,>=7.11; extra == 'test'
|
|
23
23
|
Requires-Dist: jsonschema<5,>=4.23; extra == 'test'
|
|
24
|
+
Requires-Dist: mypy<2,>=1.15; extra == 'test'
|
|
24
25
|
Requires-Dist: pytest-asyncio<2,>=0.24; extra == 'test'
|
|
25
26
|
Requires-Dist: pytest<10,>=8; extra == 'test'
|
|
26
27
|
Description-Content-Type: text/markdown
|
|
@@ -50,6 +51,9 @@ any Redis client connections.
|
|
|
50
51
|
|
|
51
52
|
## A cached function
|
|
52
53
|
|
|
54
|
+
Select cache identity explicitly with `cache_key`. This API excerpt assumes an
|
|
55
|
+
application `database`:
|
|
56
|
+
|
|
53
57
|
```python
|
|
54
58
|
from dialcache import DialCache, Policy
|
|
55
59
|
|
|
@@ -59,20 +63,33 @@ cache = DialCache(namespace="my-service")
|
|
|
59
63
|
@cache.cached(
|
|
60
64
|
use_case="user-profile",
|
|
61
65
|
key_type="user",
|
|
62
|
-
|
|
66
|
+
cache_key=lambda user_id, locale: {"id": user_id, "args": {"locale": locale}},
|
|
63
67
|
default_config=Policy(ttl_sec={"local": 5}, request_local=True),
|
|
64
68
|
)
|
|
65
|
-
async def get_profile(user_id: str) -> dict:
|
|
66
|
-
return await database.fetch_profile(user_id)
|
|
69
|
+
async def get_profile(user_id: str, locale: str) -> dict:
|
|
70
|
+
return await database.fetch_profile(user_id, locale)
|
|
67
71
|
|
|
68
72
|
|
|
69
|
-
async def handle_request(user_id: str) -> dict:
|
|
73
|
+
async def handle_request(user_id: str, locale: str) -> dict:
|
|
70
74
|
async with cache.enable():
|
|
71
|
-
first = await get_profile(user_id)
|
|
72
|
-
second = await get_profile(user_id) # Same request memo.
|
|
75
|
+
first = await get_profile(user_id, locale)
|
|
76
|
+
second = await get_profile(user_id, locale) # Same request memo.
|
|
73
77
|
return second
|
|
74
78
|
```
|
|
75
79
|
|
|
80
|
+
The selector returns an entity ID, or `{"id": ..., "args": {...}}` when the
|
|
81
|
+
result has additional dimensions. Here, `id` groups the user's tracked values
|
|
82
|
+
for invalidation; `args` distinguishes locales within this use case. Include
|
|
83
|
+
every input that affects the result, including tenant or authorization scope
|
|
84
|
+
when relevant. Explicit `use_case` and argument names keep identity stable
|
|
85
|
+
across function refactors and languages.
|
|
86
|
+
|
|
87
|
+
The selector receives the same positional and keyword arguments as the loader.
|
|
88
|
+
Match their parameter names and repeat any defaults the loader accepts; loader
|
|
89
|
+
defaults are not filled in before calling the selector. For inferred identity,
|
|
90
|
+
`id_arg`, `arg_adapters`, and `ignore_args` remain supported alternatives; see
|
|
91
|
+
the [Python identity guide](https://lan17.github.io/DialCache/languages/python.html#identity-and-policy).
|
|
92
|
+
|
|
76
93
|
Calls outside `enable()` go directly to the source. They do not construct cache
|
|
77
94
|
keys, resolve policy, share concurrent work, or apply a DialCache source
|
|
78
95
|
deadline. Both `with cache.enable():` and `async with cache.enable():` are valid;
|
|
@@ -157,7 +174,7 @@ cache = DialCache(redis=RedisAdapter(client))
|
|
|
157
174
|
@cache.cached(
|
|
158
175
|
use_case="user-profile",
|
|
159
176
|
key_type="user",
|
|
160
|
-
|
|
177
|
+
cache_key=lambda user_id: user_id,
|
|
161
178
|
track_for_invalidation=True,
|
|
162
179
|
default_config=Policy(ttl_sec={"remote": 60}),
|
|
163
180
|
)
|
|
@@ -23,6 +23,9 @@ any Redis client connections.
|
|
|
23
23
|
|
|
24
24
|
## A cached function
|
|
25
25
|
|
|
26
|
+
Select cache identity explicitly with `cache_key`. This API excerpt assumes an
|
|
27
|
+
application `database`:
|
|
28
|
+
|
|
26
29
|
```python
|
|
27
30
|
from dialcache import DialCache, Policy
|
|
28
31
|
|
|
@@ -32,20 +35,33 @@ cache = DialCache(namespace="my-service")
|
|
|
32
35
|
@cache.cached(
|
|
33
36
|
use_case="user-profile",
|
|
34
37
|
key_type="user",
|
|
35
|
-
|
|
38
|
+
cache_key=lambda user_id, locale: {"id": user_id, "args": {"locale": locale}},
|
|
36
39
|
default_config=Policy(ttl_sec={"local": 5}, request_local=True),
|
|
37
40
|
)
|
|
38
|
-
async def get_profile(user_id: str) -> dict:
|
|
39
|
-
return await database.fetch_profile(user_id)
|
|
41
|
+
async def get_profile(user_id: str, locale: str) -> dict:
|
|
42
|
+
return await database.fetch_profile(user_id, locale)
|
|
40
43
|
|
|
41
44
|
|
|
42
|
-
async def handle_request(user_id: str) -> dict:
|
|
45
|
+
async def handle_request(user_id: str, locale: str) -> dict:
|
|
43
46
|
async with cache.enable():
|
|
44
|
-
first = await get_profile(user_id)
|
|
45
|
-
second = await get_profile(user_id) # Same request memo.
|
|
47
|
+
first = await get_profile(user_id, locale)
|
|
48
|
+
second = await get_profile(user_id, locale) # Same request memo.
|
|
46
49
|
return second
|
|
47
50
|
```
|
|
48
51
|
|
|
52
|
+
The selector returns an entity ID, or `{"id": ..., "args": {...}}` when the
|
|
53
|
+
result has additional dimensions. Here, `id` groups the user's tracked values
|
|
54
|
+
for invalidation; `args` distinguishes locales within this use case. Include
|
|
55
|
+
every input that affects the result, including tenant or authorization scope
|
|
56
|
+
when relevant. Explicit `use_case` and argument names keep identity stable
|
|
57
|
+
across function refactors and languages.
|
|
58
|
+
|
|
59
|
+
The selector receives the same positional and keyword arguments as the loader.
|
|
60
|
+
Match their parameter names and repeat any defaults the loader accepts; loader
|
|
61
|
+
defaults are not filled in before calling the selector. For inferred identity,
|
|
62
|
+
`id_arg`, `arg_adapters`, and `ignore_args` remain supported alternatives; see
|
|
63
|
+
the [Python identity guide](https://lan17.github.io/DialCache/languages/python.html#identity-and-policy).
|
|
64
|
+
|
|
49
65
|
Calls outside `enable()` go directly to the source. They do not construct cache
|
|
50
66
|
keys, resolve policy, share concurrent work, or apply a DialCache source
|
|
51
67
|
deadline. Both `with cache.enable():` and `async with cache.enable():` are valid;
|
|
@@ -130,7 +146,7 @@ cache = DialCache(redis=RedisAdapter(client))
|
|
|
130
146
|
@cache.cached(
|
|
131
147
|
use_case="user-profile",
|
|
132
148
|
key_type="user",
|
|
133
|
-
|
|
149
|
+
cache_key=lambda user_id: user_id,
|
|
134
150
|
track_for_invalidation=True,
|
|
135
151
|
default_config=Policy(ttl_sec={"remote": 60}),
|
|
136
152
|
)
|
|
@@ -12,7 +12,7 @@ from .errors import (
|
|
|
12
12
|
UseCaseIsAlreadyRegisteredError,
|
|
13
13
|
UseCaseNameIsReservedError,
|
|
14
14
|
)
|
|
15
|
-
from .key import Key, normalize_args
|
|
15
|
+
from .key import CacheKeySpec, Key, KeyScalar, normalize_args
|
|
16
16
|
from .serializer import UNDEFINED, JsonSerializer, Serializer
|
|
17
17
|
|
|
18
18
|
__all__ = [
|
|
@@ -22,6 +22,8 @@ __all__ = [
|
|
|
22
22
|
"DialCacheKeyConfig",
|
|
23
23
|
"CacheLayer",
|
|
24
24
|
"Key",
|
|
25
|
+
"CacheKeySpec",
|
|
26
|
+
"KeyScalar",
|
|
25
27
|
"normalize_args",
|
|
26
28
|
"Serializer",
|
|
27
29
|
"JsonSerializer",
|
|
@@ -14,9 +14,9 @@ import inspect
|
|
|
14
14
|
import json
|
|
15
15
|
import logging
|
|
16
16
|
import math
|
|
17
|
-
from collections.abc import Awaitable, Callable, Mapping
|
|
17
|
+
from collections.abc import Awaitable, Callable, Coroutine, Mapping
|
|
18
18
|
from dataclasses import dataclass, field
|
|
19
|
-
from typing import Any, ParamSpec, TypeVar
|
|
19
|
+
from typing import Any, ParamSpec, Protocol, TypeVar, overload
|
|
20
20
|
|
|
21
21
|
from .clock import SystemClock
|
|
22
22
|
from .config import UNSET, Policy, _valid_ramp, merge_policy, resolve_layer, validate_static_policy
|
|
@@ -29,7 +29,7 @@ from .errors import (
|
|
|
29
29
|
UseCaseIsAlreadyRegisteredError,
|
|
30
30
|
UseCaseNameIsReservedError,
|
|
31
31
|
)
|
|
32
|
-
from .key import Key, invalidation_prefix, normalize_args, ramp_sample
|
|
32
|
+
from .key import Key, KeyScalar, invalidation_prefix, normalize_args, ramp_sample
|
|
33
33
|
from .local import LocalCache
|
|
34
34
|
from .metrics import Metrics, emit_metric
|
|
35
35
|
from .protocol import Frame, Miss, compress_payload, decompress_payload, escape_raw_payload, utf8_bytes
|
|
@@ -41,6 +41,14 @@ P = ParamSpec("P")
|
|
|
41
41
|
MAX_SAFE = 9_007_199_254_740_991
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
class _CachedDecorator(Protocol):
|
|
45
|
+
@overload
|
|
46
|
+
def __call__(self, fn: Callable[P, Awaitable[T]], /) -> Callable[P, Coroutine[Any, Any, T]]: ...
|
|
47
|
+
|
|
48
|
+
@overload
|
|
49
|
+
def __call__(self, fn: Callable[P, T | Awaitable[T]], /) -> Callable[P, Coroutine[Any, Any, T]]: ...
|
|
50
|
+
|
|
51
|
+
|
|
44
52
|
async def _await(value: Any) -> Any:
|
|
45
53
|
return await value if inspect.isawaitable(value) else value
|
|
46
54
|
|
|
@@ -243,22 +251,31 @@ class DialCache:
|
|
|
243
251
|
self,
|
|
244
252
|
*,
|
|
245
253
|
key_type: str,
|
|
246
|
-
cache_key: Callable[...,
|
|
254
|
+
cache_key: Callable[..., Mapping[str, object] | KeyScalar | Key] | None = None,
|
|
247
255
|
id_arg: str | tuple[str, Callable[[Any], Any]] | None = None,
|
|
248
256
|
use_case: str | None = None,
|
|
249
257
|
arg_adapters: Mapping[str, Callable[[Any], Any]] | None = None,
|
|
250
258
|
ignore_args: list[str] | tuple[str, ...] = (),
|
|
251
259
|
**options: Any,
|
|
252
|
-
) ->
|
|
253
|
-
"""Decorate a loader
|
|
260
|
+
) -> _CachedDecorator:
|
|
261
|
+
"""Decorate a loader with explicit cache_key selection (recommended) or id_arg.
|
|
254
262
|
|
|
255
263
|
The wrapper is always awaitable, including for a synchronous loader.
|
|
256
264
|
Key callbacks and argument adapters are never called while disabled.
|
|
265
|
+
An explicit selector receives the original positional and keyword
|
|
266
|
+
arguments, including self for methods. Loader defaults are not injected:
|
|
267
|
+
declare matching defaults on the selector when callers can omit them.
|
|
257
268
|
"""
|
|
258
269
|
if (cache_key is None) == (id_arg is None):
|
|
259
270
|
raise ConfigError("Supply exactly one of cache_key or id_arg")
|
|
260
271
|
|
|
261
|
-
|
|
272
|
+
@overload
|
|
273
|
+
def decorate(fn: Callable[P, Awaitable[T]]) -> Callable[P, Coroutine[Any, Any, T]]: ...
|
|
274
|
+
|
|
275
|
+
@overload
|
|
276
|
+
def decorate(fn: Callable[P, T | Awaitable[T]]) -> Callable[P, Coroutine[Any, Any, T]]: ...
|
|
277
|
+
|
|
278
|
+
def decorate(fn: Callable[P, T | Awaitable[T]]) -> Callable[P, Coroutine[Any, Any, T]]:
|
|
262
279
|
name = use_case or f"{fn.__module__}.{fn.__qualname__}"
|
|
263
280
|
self._check_use_case(name)
|
|
264
281
|
if name in self._registered:
|
|
@@ -5,9 +5,19 @@ from __future__ import annotations
|
|
|
5
5
|
import math
|
|
6
6
|
from collections.abc import Mapping, Sequence
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
|
+
from typing import NotRequired, TypeAlias, TypedDict
|
|
8
9
|
from urllib.parse import quote
|
|
9
10
|
|
|
10
|
-
from .serializer import UNDEFINED
|
|
11
|
+
from .serializer import UNDEFINED, _Undefined
|
|
12
|
+
|
|
13
|
+
KeyScalar: TypeAlias = str | int | float | bool | None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CacheKeySpec(TypedDict):
|
|
17
|
+
"""An entity id and the explicit result dimensions selected for a cache key."""
|
|
18
|
+
|
|
19
|
+
id: KeyScalar
|
|
20
|
+
args: NotRequired[Mapping[str, KeyScalar | _Undefined]]
|
|
11
21
|
|
|
12
22
|
|
|
13
23
|
def _integer_string(value: int) -> str:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dialcache"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.26.0"
|
|
8
8
|
description = "Explicitly enabled async caching with runtime policies, coalescing, and tracked Redis invalidation."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -24,7 +24,7 @@ classifiers = [
|
|
|
24
24
|
|
|
25
25
|
[project.optional-dependencies]
|
|
26
26
|
redis = ["redis>=5,<7"]
|
|
27
|
-
test = ["pytest>=8,<10", "pytest-asyncio>=0.24,<2", "jsonschema>=4.23,<5", "coverage>=7.11,<8"]
|
|
27
|
+
test = ["pytest>=8,<10", "pytest-asyncio>=0.24,<2", "jsonschema>=4.23,<5", "coverage>=7.11,<8", "mypy>=1.15,<2"]
|
|
28
28
|
|
|
29
29
|
[project.urls]
|
|
30
30
|
Repository = "https://github.com/lan17/DialCache"
|
|
@@ -49,18 +49,27 @@ class Acceptance:
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
root = Path(__file__).resolve().parents[2]
|
|
52
|
+
suite = sys.argv[2:] or ["--suite", "native"]
|
|
53
|
+
if suite not in (["--suite", "native"], ["--suite", "wire"]):
|
|
54
|
+
raise SystemExit("Expected report path followed by --suite native|wire")
|
|
55
|
+
files = (
|
|
56
|
+
[root / "interop/test_wire_interop.py"]
|
|
57
|
+
if suite[1] == "wire"
|
|
58
|
+
else [root / "python/tests/test_redis_integration.py", root / "python/tests/test_docs_examples.py"]
|
|
59
|
+
)
|
|
52
60
|
gate = Acceptance()
|
|
53
61
|
status = pytest.main(
|
|
54
62
|
[
|
|
55
63
|
"-c",
|
|
56
64
|
str(root / "python/pyproject.toml"),
|
|
65
|
+
"--rootdir",
|
|
66
|
+
str(root),
|
|
57
67
|
"-o",
|
|
58
68
|
"addopts=",
|
|
59
69
|
"--noconftest",
|
|
60
70
|
"-p",
|
|
61
71
|
"pytest_asyncio.plugin",
|
|
62
|
-
str
|
|
63
|
-
str(root / "python/tests/test_docs_examples.py"),
|
|
72
|
+
*map(str, files),
|
|
64
73
|
"-m",
|
|
65
74
|
"integration",
|
|
66
75
|
"-q",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Public explicit-selector semantics, including Python invocation conventions."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from dialcache import UNDEFINED, DialCache, Key, Policy
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def test_selector_receives_original_positional_and_keyword_arguments():
|
|
9
|
+
cache = DialCache()
|
|
10
|
+
seen = []
|
|
11
|
+
|
|
12
|
+
def select(*args, **kwargs):
|
|
13
|
+
seen.append((args, kwargs))
|
|
14
|
+
return args[0] if args else kwargs["user_id"]
|
|
15
|
+
|
|
16
|
+
@cache.cached(key_type="user", cache_key=select, default_config=Policy(request_local=True))
|
|
17
|
+
async def load(user_id, locale="en"):
|
|
18
|
+
return user_id
|
|
19
|
+
|
|
20
|
+
# Disabled calls do not invoke the selector, even for defaulted parameters.
|
|
21
|
+
assert await load("42") == "42"
|
|
22
|
+
assert seen == []
|
|
23
|
+
with cache.enable():
|
|
24
|
+
assert await load("42") == "42"
|
|
25
|
+
assert await load(user_id="42") == "42"
|
|
26
|
+
assert seen == [(("42",), {}), ((), {"user_id": "42"})]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def test_explicit_selector_positional_only_keyword_only_and_variadic_arguments():
|
|
30
|
+
cache = DialCache()
|
|
31
|
+
calls = 0
|
|
32
|
+
|
|
33
|
+
def select(user_id, /, *roles, locale="en", **context):
|
|
34
|
+
return {"id": user_id, "args": {"roles": ",".join(roles), "locale": locale}}
|
|
35
|
+
|
|
36
|
+
@cache.cached(key_type="user", cache_key=select, default_config=Policy(request_local=True))
|
|
37
|
+
async def load(user_id, /, *roles, locale="en", **context):
|
|
38
|
+
nonlocal calls
|
|
39
|
+
calls += 1
|
|
40
|
+
return calls
|
|
41
|
+
|
|
42
|
+
with cache.enable():
|
|
43
|
+
assert await load("42", "reader", trace_id="first") == 1
|
|
44
|
+
assert await load("42", "reader", locale="en", trace_id="second") == 1
|
|
45
|
+
assert await load("42", "admin") == 2
|
|
46
|
+
assert await load("42", "reader", locale="fr") == 3
|
|
47
|
+
assert calls == 3
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def test_method_selector_can_include_instance_identity():
|
|
51
|
+
cache = DialCache()
|
|
52
|
+
calls = []
|
|
53
|
+
|
|
54
|
+
class Users:
|
|
55
|
+
def __init__(self, tenant):
|
|
56
|
+
self.tenant = tenant
|
|
57
|
+
|
|
58
|
+
@cache.cached(
|
|
59
|
+
key_type="user",
|
|
60
|
+
cache_key=lambda self, user_id: {"id": user_id, "args": {"tenant": self.tenant}},
|
|
61
|
+
default_config=Policy(request_local=True),
|
|
62
|
+
)
|
|
63
|
+
async def load(self, user_id):
|
|
64
|
+
calls.append((self.tenant, user_id))
|
|
65
|
+
return self.tenant
|
|
66
|
+
|
|
67
|
+
first, second = Users("a"), Users("b")
|
|
68
|
+
with cache.enable():
|
|
69
|
+
assert await first.load("42") == "a"
|
|
70
|
+
assert await first.load(user_id="42") == "a"
|
|
71
|
+
assert await second.load("42") == "b"
|
|
72
|
+
assert calls == [("a", "42"), ("b", "42")]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_selector_missing_default_fails_open(caplog):
|
|
76
|
+
cache = DialCache()
|
|
77
|
+
calls = 0
|
|
78
|
+
|
|
79
|
+
@cache.cached(
|
|
80
|
+
key_type="user",
|
|
81
|
+
cache_key=lambda user_id, locale: {"id": user_id, "args": {"locale": locale}},
|
|
82
|
+
default_config=Policy(request_local=True),
|
|
83
|
+
)
|
|
84
|
+
async def load(user_id, locale="en"):
|
|
85
|
+
nonlocal calls
|
|
86
|
+
calls += 1
|
|
87
|
+
return calls
|
|
88
|
+
|
|
89
|
+
with cache.enable():
|
|
90
|
+
assert await load("42") == 1
|
|
91
|
+
assert await load("42") == 2
|
|
92
|
+
assert await load("42", "en") == 3
|
|
93
|
+
assert await load("42", "en") == 3
|
|
94
|
+
assert "Could not construct DialCache key" in caplog.text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.parametrize(
|
|
98
|
+
"selected",
|
|
99
|
+
[
|
|
100
|
+
"42",
|
|
101
|
+
{"id": "42", "args": {"locale": UNDEFINED}},
|
|
102
|
+
Key("urn", "user", "42", "GetUser"),
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
async def test_existing_scalar_mapping_and_structured_key_selectors(selected):
|
|
106
|
+
cache = DialCache()
|
|
107
|
+
calls = 0
|
|
108
|
+
|
|
109
|
+
@cache.cached(
|
|
110
|
+
key_type="user",
|
|
111
|
+
use_case="GetUser",
|
|
112
|
+
cache_key=lambda user_id: selected,
|
|
113
|
+
default_config=Policy(request_local=True),
|
|
114
|
+
)
|
|
115
|
+
async def load(user_id):
|
|
116
|
+
nonlocal calls
|
|
117
|
+
calls += 1
|
|
118
|
+
return calls
|
|
119
|
+
|
|
120
|
+
with cache.enable():
|
|
121
|
+
assert await load("42") == 1
|
|
122
|
+
assert await load("42") == 1
|
|
123
|
+
assert calls == 1
|
|
@@ -9,6 +9,37 @@ from dialcache import DialCache, Policy
|
|
|
9
9
|
from dialcache.redis import RedisAdapter
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
async def test_explicit_key_identity():
|
|
13
|
+
cache = DialCache(namespace="users-api")
|
|
14
|
+
source_calls = []
|
|
15
|
+
|
|
16
|
+
@cache.cached(
|
|
17
|
+
key_type="user_id",
|
|
18
|
+
use_case="GetUser",
|
|
19
|
+
# Repeat the loader default so omitted and explicit locales agree.
|
|
20
|
+
cache_key=lambda user_id, locale="en", *, trace_id=None: {
|
|
21
|
+
"id": user_id,
|
|
22
|
+
"args": {"locale": locale},
|
|
23
|
+
},
|
|
24
|
+
default_config=Policy(request_local=True),
|
|
25
|
+
)
|
|
26
|
+
async def get_user(user_id: str, locale: str = "en", *, trace_id=None):
|
|
27
|
+
source_calls.append((user_id, locale, trace_id))
|
|
28
|
+
return {"id": user_id, "locale": locale}
|
|
29
|
+
|
|
30
|
+
async with cache.enable():
|
|
31
|
+
assert await get_user("123", trace_id="first") == {"id": "123", "locale": "en"}
|
|
32
|
+
assert await get_user(user_id="123", locale="en", trace_id="second") == {
|
|
33
|
+
"id": "123",
|
|
34
|
+
"locale": "en",
|
|
35
|
+
}
|
|
36
|
+
assert await get_user("123", "fr") == {"id": "123", "locale": "fr"}
|
|
37
|
+
assert await get_user("456") == {"id": "456", "locale": "en"}
|
|
38
|
+
|
|
39
|
+
# Trace metadata does not alter the result, so it is omitted from identity.
|
|
40
|
+
assert source_calls == [("123", "en", "first"), ("123", "fr", None), ("456", "en", None)]
|
|
41
|
+
|
|
42
|
+
|
|
12
43
|
async def test_request_scope():
|
|
13
44
|
# region request-scope
|
|
14
45
|
cache = DialCache()
|
|
@@ -17,7 +48,7 @@ async def test_request_scope():
|
|
|
17
48
|
@cache.cached(
|
|
18
49
|
key_type="user",
|
|
19
50
|
use_case="requestScope",
|
|
20
|
-
|
|
51
|
+
cache_key=lambda id: id,
|
|
21
52
|
# Only request-local storage is enabled; shared layers stay off.
|
|
22
53
|
default_config=Policy(request_local=True),
|
|
23
54
|
)
|
|
@@ -49,7 +80,7 @@ async def test_runtime_policy():
|
|
|
49
80
|
@cache.cached(
|
|
50
81
|
key_type="user",
|
|
51
82
|
use_case="runtimePolicy",
|
|
52
|
-
|
|
83
|
+
cache_key=lambda id: id,
|
|
53
84
|
default_config=Policy(request_local=True, ttl_sec={"local": 60}, ramp={"local": 100}),
|
|
54
85
|
)
|
|
55
86
|
async def lookup(id):
|
|
@@ -92,7 +123,7 @@ async def test_tracked_invalidation():
|
|
|
92
123
|
@cache.cached(
|
|
93
124
|
key_type="user",
|
|
94
125
|
use_case="profileVersion",
|
|
95
|
-
|
|
126
|
+
cache_key=lambda id: id,
|
|
96
127
|
track_for_invalidation=True,
|
|
97
128
|
# Keep local layers off so each request observes the watermark.
|
|
98
129
|
default_config=Policy(ttl_sec={"remote": 60}, ramp={"remote": 100}),
|
|
@@ -50,6 +50,8 @@ def pytest_collection_modifyitems(session, config, items):
|
|
|
50
50
|
mode = os.environ.get("PROBE_MODE")
|
|
51
51
|
if mode == "subset":
|
|
52
52
|
items[:] = [item for item in items if "inventory_only" in item.nodeid]
|
|
53
|
+
elif mode == "exclude_mixed_client":
|
|
54
|
+
items[:] = [item for item in items if "test_wire_interop.py" not in item.nodeid]
|
|
53
55
|
elif mode == "duplicate":
|
|
54
56
|
items.append(next(item for item in items if item.get_closest_marker("integration")))
|
|
55
57
|
elif mode == "empty":
|
|
@@ -63,24 +65,33 @@ def pytest_runtestloop(session):
|
|
|
63
65
|
"""
|
|
64
66
|
|
|
65
67
|
|
|
68
|
+
CHALLENGES = [
|
|
69
|
+
"baseline",
|
|
70
|
+
"inherited_keyword",
|
|
71
|
+
"configured_keyword",
|
|
72
|
+
"inherited_collect_only",
|
|
73
|
+
"external_plugin_ignored",
|
|
74
|
+
"subset",
|
|
75
|
+
"exclude_mixed_client",
|
|
76
|
+
"duplicate",
|
|
77
|
+
"empty",
|
|
78
|
+
"skip",
|
|
79
|
+
"fail",
|
|
80
|
+
"xpass",
|
|
81
|
+
"no_reports",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
66
85
|
@pytest.mark.parametrize(
|
|
67
|
-
"challenge",
|
|
86
|
+
"suite,challenge",
|
|
68
87
|
[
|
|
69
|
-
|
|
70
|
-
"
|
|
71
|
-
|
|
72
|
-
"
|
|
73
|
-
"external_plugin_ignored",
|
|
74
|
-
"subset",
|
|
75
|
-
"duplicate",
|
|
76
|
-
"empty",
|
|
77
|
-
"skip",
|
|
78
|
-
"fail",
|
|
79
|
-
"xpass",
|
|
80
|
-
"no_reports",
|
|
88
|
+
(suite, challenge)
|
|
89
|
+
for suite in ["native", "wire"]
|
|
90
|
+
for challenge in CHALLENGES
|
|
91
|
+
if suite == "wire" or challenge != "exclude_mixed_client"
|
|
81
92
|
],
|
|
82
93
|
)
|
|
83
|
-
def test_integration_coordinator_requires_complete_cases_on_each_backend(tmp_path, challenge):
|
|
94
|
+
def test_integration_coordinator_requires_complete_cases_on_each_backend(tmp_path, suite, challenge):
|
|
84
95
|
node = os.environ.get("NODE") or shutil.which("node")
|
|
85
96
|
assert node, "Node 24 is required by Python validation"
|
|
86
97
|
checkout = tmp_path
|
|
@@ -97,7 +108,7 @@ def test_integration_coordinator_requires_complete_cases_on_each_backend(tmp_pat
|
|
|
97
108
|
'markers=["integration: required real-server case"]\n'
|
|
98
109
|
f"addopts={json.dumps(configured)}\n"
|
|
99
110
|
)
|
|
100
|
-
failures = {"subset", "duplicate", "empty", "skip", "fail", "xpass", "no_reports"}
|
|
111
|
+
failures = {"subset", "exclude_mixed_client", "duplicate", "empty", "skip", "fail", "xpass", "no_reports"}
|
|
101
112
|
mode = challenge if challenge in failures else "pass"
|
|
102
113
|
external_plugin = challenge == "external_plugin_ignored"
|
|
103
114
|
if external_plugin:
|
|
@@ -106,6 +117,12 @@ def test_integration_coordinator_requires_complete_cases_on_each_backend(tmp_pat
|
|
|
106
117
|
TEST.format(plugins='pytest_plugins = ["challenge_plugin"]' if challenge in failures else "")
|
|
107
118
|
)
|
|
108
119
|
(tests / "test_docs_examples.py").write_text(DOCS)
|
|
120
|
+
(checkout / "interop").mkdir()
|
|
121
|
+
(checkout / "interop/test_wire_interop.py").write_text(
|
|
122
|
+
TEST.format(plugins='pytest_plugins = ["challenge_plugin"]' if challenge in failures else "")
|
|
123
|
+
.replace('"雪"', '"wire雪"')
|
|
124
|
+
.replace('"<not-xml>"', '"wire<not-xml>"')
|
|
125
|
+
)
|
|
109
126
|
(tests / "challenge_plugin.py").write_text(PLUGIN)
|
|
110
127
|
coverage = checkout / "coverage/python"
|
|
111
128
|
coverage.mkdir(parents=True)
|
|
@@ -130,7 +147,7 @@ def test_integration_coordinator_requires_complete_cases_on_each_backend(tmp_pat
|
|
|
130
147
|
"PYTEST_PLUGINS": "challenge_plugin" if external_plugin else "",
|
|
131
148
|
}
|
|
132
149
|
result = subprocess.run(
|
|
133
|
-
[node, "formal/run-python-integration.mjs"],
|
|
150
|
+
[node, "formal/run-python-integration.mjs", "--suite", suite],
|
|
134
151
|
cwd=checkout,
|
|
135
152
|
env=environment,
|
|
136
153
|
text=True,
|
|
@@ -140,14 +157,22 @@ def test_integration_coordinator_requires_complete_cases_on_each_backend(tmp_pat
|
|
|
140
157
|
if challenge in failures:
|
|
141
158
|
assert result.returncode != 0, result.stdout + result.stderr
|
|
142
159
|
assert "Integration acceptance failed:" in result.stderr, result.stdout + result.stderr
|
|
143
|
-
|
|
160
|
+
if suite == "native":
|
|
161
|
+
assert not list(coverage.glob("*.lcov"))
|
|
144
162
|
else:
|
|
145
163
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
146
164
|
for backend in ["Redis", "Valkey"]:
|
|
147
|
-
|
|
148
|
-
|
|
165
|
+
if suite == "native":
|
|
166
|
+
report = (coverage / f"{backend}.lcov").read_text()
|
|
167
|
+
assert "SF:" in report and "stale report" not in report
|
|
168
|
+
else:
|
|
169
|
+
assert (checkout / f".formal-traces/wire-integration-{backend}.xml").is_file()
|
|
149
170
|
assert log.read_text().splitlines() == [
|
|
150
171
|
f"{url} {value}"
|
|
151
172
|
for url in [environment["TEST_REDIS_URL"], environment["TEST_VALKEY_URL"]]
|
|
152
|
-
for value in ["雪", "<not-xml>"]
|
|
173
|
+
for value in (["雪", "<not-xml>"] if suite == "native" else ["wire雪", "wire<not-xml>"])
|
|
153
174
|
]
|
|
175
|
+
if suite == "wire":
|
|
176
|
+
# Wire evidence must not replace or clear native Python Codecov data.
|
|
177
|
+
for backend in ["Redis", "Valkey"]:
|
|
178
|
+
assert (coverage / f"{backend}.lcov").read_text() == "stale report must not survive a failed run"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Check the public decorator as a typed consumer, without checking engine internals."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_cached_consumer_types(tmp_path):
|
|
10
|
+
source_root = Path(__file__).resolve().parents[1]
|
|
11
|
+
fixture = Path(__file__).with_name("typing") / "cached.py"
|
|
12
|
+
environment = os.environ.copy()
|
|
13
|
+
environment["MYPYPATH"] = os.pathsep.join(
|
|
14
|
+
filter(None, [str(source_root), environment.get("MYPYPATH")])
|
|
15
|
+
)
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
[
|
|
18
|
+
sys.executable,
|
|
19
|
+
"-m",
|
|
20
|
+
"mypy",
|
|
21
|
+
"--strict",
|
|
22
|
+
"--follow-imports=silent",
|
|
23
|
+
"--python-version=3.11",
|
|
24
|
+
"--cache-dir",
|
|
25
|
+
str(tmp_path / "mypy"),
|
|
26
|
+
str(fixture),
|
|
27
|
+
],
|
|
28
|
+
env=environment,
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
check=False,
|
|
32
|
+
)
|
|
33
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Keep the cross-language inventory complete and its value assertions strict."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from itertools import permutations
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
10
|
+
spec = importlib.util.spec_from_file_location("wire_cases", ROOT / "interop/test_wire_interop.py")
|
|
11
|
+
wire = importlib.util.module_from_spec(spec)
|
|
12
|
+
spec.loader.exec_module(wire)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_all_directed_pairs_and_supported_value_domains_are_required():
|
|
16
|
+
languages = {"typescript", "go", "rust", "python"}
|
|
17
|
+
assert set(wire.LANGUAGES) == languages
|
|
18
|
+
assert set(wire.DIRECTIONS) == set(permutations(languages, 2))
|
|
19
|
+
assert len(wire.DIRECTIONS) == 12
|
|
20
|
+
assert len(wire.VALUE_CASES) == 69
|
|
21
|
+
common = {"json-raw", "json-zstd", "null", "binary-escaped", "binary-zstd"}
|
|
22
|
+
for writer, reader in wire.DIRECTIONS:
|
|
23
|
+
cases = {
|
|
24
|
+
case.id.removeprefix(f"{writer}-to-{reader}-")
|
|
25
|
+
for case in wire.VALUE_CASES
|
|
26
|
+
if case.values[:2] == (writer, reader)
|
|
27
|
+
}
|
|
28
|
+
assert cases == common | ({"undefined"} if writer != "rust" else set())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize("actual,expected", [(False, 0), (0, False), (True, 1), (1, True)])
|
|
32
|
+
def test_json_equality_rejects_boolean_number_substitution(actual, expected):
|
|
33
|
+
with pytest.raises(AssertionError):
|
|
34
|
+
wire.assert_value({"nested": [actual]}, {"nested": [expected]})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_json_equality_accepts_equivalent_native_number_representations():
|
|
38
|
+
wire.assert_value({"nested": [None, False, True, 1.0]}, {"nested": [None, False, True, 1]})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Consumer type checks: unused ignores turn unexpectedly accepted bad calls into failures."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Coroutine, Mapping
|
|
5
|
+
from typing import Any, assert_type
|
|
6
|
+
|
|
7
|
+
from dialcache import UNDEFINED, CacheKeySpec, DialCache, Key, KeyScalar
|
|
8
|
+
|
|
9
|
+
cache = DialCache()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def select_user(user_id: str, *, locale: str = "en") -> CacheKeySpec:
|
|
13
|
+
return {"id": user_id, "args": {"locale": locale, "omitted": UNDEFINED}}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@cache.cached(key_type="user", cache_key=select_user)
|
|
17
|
+
async def get_user(user_id: str, *, locale: str = "en") -> str:
|
|
18
|
+
return user_id + locale
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@cache.cached(
|
|
22
|
+
key_type="number",
|
|
23
|
+
cache_key=lambda value, multiplier=2: {"id": value, "args": {"multiplier": multiplier}},
|
|
24
|
+
)
|
|
25
|
+
def get_number(value: int, /, multiplier: int = 2) -> int:
|
|
26
|
+
return value * multiplier
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@cache.cached(key_type="awaitable", cache_key=lambda value: value)
|
|
30
|
+
def get_awaitable(value: str) -> Awaitable[str]:
|
|
31
|
+
return asyncio.sleep(0, result=value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@cache.cached(key_type="mixed", cache_key=lambda value, deferred=False: value)
|
|
35
|
+
def get_mixed(value: str, *, deferred: bool = False) -> str | Awaitable[str]:
|
|
36
|
+
return asyncio.sleep(0, result=value) if deferred else value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@cache.cached(key_type="user", id_arg="user_id")
|
|
40
|
+
async def inferred_user(user_id: str) -> str:
|
|
41
|
+
return user_id
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@cache.cached(key_type="user", id_arg="user_id")
|
|
45
|
+
def inferred_sync_user(user_id: str) -> str:
|
|
46
|
+
return user_id
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Users:
|
|
50
|
+
@cache.cached(key_type="user", cache_key=lambda self, user_id: user_id)
|
|
51
|
+
async def get(self, user_id: str) -> str:
|
|
52
|
+
return user_id
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def select_key(user_id: str) -> Key:
|
|
56
|
+
return Key("urn", "user", user_id, "GetUser")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def select_scalar(user_id: str) -> KeyScalar:
|
|
60
|
+
return user_id
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def select_dict(user_id: str) -> dict[str, str]:
|
|
64
|
+
return {"id": user_id}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def select_mapping(user_id: str) -> Mapping[str, object]:
|
|
68
|
+
return {"id": user_id, "args": {"locale": "en"}}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
cache.cached(key_type="user", cache_key=select_key)
|
|
72
|
+
cache.cached(key_type="user", cache_key=select_scalar)
|
|
73
|
+
cache.cached(key_type="user", cache_key=select_dict)
|
|
74
|
+
cache.cached(key_type="user", cache_key=select_mapping)
|
|
75
|
+
cache.cached(key_type="user", cache_key=lambda user_id: {"id": user_id})
|
|
76
|
+
cache.cached(key_type="user", cache_key=lambda user_id: {"id": user_id, "args": {"locale": "en"}})
|
|
77
|
+
# Generic mappings retain runtime validation; opt into CacheKeySpec for field checks.
|
|
78
|
+
cache.cached(key_type="user", cache_key=lambda user_id: {"id": user_id, "args": {"nested": []}})
|
|
79
|
+
|
|
80
|
+
assert_type(get_user("42"), Coroutine[Any, Any, str]).close()
|
|
81
|
+
assert_type(get_user(user_id="42", locale="fr"), Coroutine[Any, Any, str]).close()
|
|
82
|
+
assert_type(get_number(2), Coroutine[Any, Any, int]).close()
|
|
83
|
+
assert_type(get_number(2, multiplier=3), Coroutine[Any, Any, int]).close()
|
|
84
|
+
assert_type(get_awaitable("42"), Coroutine[Any, Any, str]).close()
|
|
85
|
+
assert_type(get_mixed("42"), Coroutine[Any, Any, str]).close()
|
|
86
|
+
assert_type(get_mixed("42", deferred=True), Coroutine[Any, Any, str]).close()
|
|
87
|
+
assert_type(inferred_user("42"), Coroutine[Any, Any, str]).close()
|
|
88
|
+
assert_type(inferred_sync_user("42"), Coroutine[Any, Any, str]).close()
|
|
89
|
+
assert_type(Users().get("42"), Coroutine[Any, Any, str]).close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def check_awaited_results() -> None:
|
|
93
|
+
assert_type(await get_user("42"), str)
|
|
94
|
+
assert_type(await get_number(2), int)
|
|
95
|
+
assert_type(await get_awaitable("42"), str)
|
|
96
|
+
assert_type(await get_mixed("42"), str)
|
|
97
|
+
assert_type(await get_mixed("42", deferred=True), str)
|
|
98
|
+
assert_type(asyncio.create_task(get_user("42")), asyncio.Task[str])
|
|
99
|
+
assert_type(asyncio.create_task(get_number(2)), asyncio.Task[int])
|
|
100
|
+
assert_type(asyncio.create_task(get_mixed("42", deferred=True)), asyncio.Task[str])
|
|
101
|
+
|
|
102
|
+
# These errors must remain errors: Any leaking from the decorator would hide them.
|
|
103
|
+
get_user(42).close() # type: ignore[arg-type]
|
|
104
|
+
get_user("42", "fr").close() # type: ignore[misc]
|
|
105
|
+
get_user("42", unknown=True).close() # type: ignore[call-arg]
|
|
106
|
+
get_user().close() # type: ignore[call-arg]
|
|
107
|
+
get_number(value=2).close() # type: ignore[call-arg]
|
|
108
|
+
inferred_user(42).close() # type: ignore[arg-type]
|
|
109
|
+
inferred_sync_user(42).close() # type: ignore[arg-type]
|
|
110
|
+
Users().get(42).close() # type: ignore[arg-type]
|
|
111
|
+
wrong_result: Awaitable[int] = get_user("42") # type: ignore[assignment]
|
|
112
|
+
cache.cached(key_type="user", cache_key=lambda user_id: object()) # type: ignore[arg-type,return-value]
|
|
113
|
+
missing_id: CacheKeySpec = {"args": {"locale": "en"}} # type: ignore[typeddict-item]
|
|
114
|
+
nested_args: CacheKeySpec = {"id": "42", "args": {"nested": []}} # type: ignore[dict-item]
|
|
115
|
+
invalid_id: CacheKeySpec = {"id": object()} # type: ignore[typeddict-item]
|
|
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
|