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.
Files changed (52) hide show
  1. {dialcache-0.25.0 → dialcache-0.26.0}/API-DESIGN.md +13 -2
  2. {dialcache-0.25.0 → dialcache-0.26.0}/PKG-INFO +25 -8
  3. {dialcache-0.25.0 → dialcache-0.26.0}/README.md +23 -7
  4. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/__init__.py +3 -1
  5. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/cache.py +24 -7
  6. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/key.py +11 -1
  7. {dialcache-0.25.0 → dialcache-0.26.0}/pyproject.toml +2 -2
  8. {dialcache-0.25.0 → dialcache-0.26.0}/tests/run_integration.py +11 -2
  9. dialcache-0.26.0/tests/test_cached_keys.py +123 -0
  10. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_docs_examples.py +34 -3
  11. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_integration_acceptance.py +45 -20
  12. dialcache-0.26.0/tests/test_typing.py +33 -0
  13. dialcache-0.26.0/tests/test_wire_suite.py +38 -0
  14. dialcache-0.26.0/tests/typing/cached.py +115 -0
  15. {dialcache-0.25.0 → dialcache-0.26.0}/.gitignore +0 -0
  16. {dialcache-0.25.0 → dialcache-0.26.0}/LICENSE +0 -0
  17. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/clock.py +0 -0
  18. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/config.py +0 -0
  19. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/context.py +0 -0
  20. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/errors.py +0 -0
  21. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/local.py +0 -0
  22. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/metrics.py +0 -0
  23. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/protocol.py +0 -0
  24. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/py.typed +0 -0
  25. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/redis.py +0 -0
  26. {dialcache-0.25.0 → dialcache-0.26.0}/dialcache/serializer.py +0 -0
  27. {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/__init__.py +0 -0
  28. {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/coordinator.py +0 -0
  29. {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/driver.py +0 -0
  30. {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/executor.py +0 -0
  31. {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/scenarios.py +0 -0
  32. {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/schema.py +0 -0
  33. {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/simple_drivers.py +0 -0
  34. {dialcache-0.25.0 → dialcache-0.26.0}/tests/formal/witness.py +0 -0
  35. {dialcache-0.25.0 → dialcache-0.26.0}/tests/run_conformance.py +0 -0
  36. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_cache.py +0 -0
  37. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_callback_cancellation.py +0 -0
  38. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_cluster_read_routing.py +0 -0
  39. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_config.py +0 -0
  40. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_conformance.py +0 -0
  41. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_context.py +0 -0
  42. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_dependency_cancellation.py +0 -0
  43. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_engine_review.py +0 -0
  44. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_local.py +0 -0
  45. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_observer_recovery_boundaries.py +0 -0
  46. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_protocol_native.py +0 -0
  47. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_protocol_vectors.py +0 -0
  48. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_redis_adapter.py +0 -0
  49. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_redis_integration.py +0 -0
  50. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_review_regressions.py +0 -0
  51. {dialcache-0.25.0 → dialcache-0.26.0}/tests/test_shadow_deadline_retention.py +0 -0
  52. {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=...)` | Retained. `id_arg` can be a parameter name or `(name, adapter)` pair. Signature binding includes defaults. |
11
- | `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. |
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.25.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
- id_arg="user_id",
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
- id_arg="user_id",
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
- id_arg="user_id",
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
- id_arg="user_id",
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[..., Any] | None = None,
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
- ) -> Callable[..., Any]:
253
- """Decorate a loader using an explicit selector or gcache-style arguments.
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
- def decorate(fn: Callable[P, T | Awaitable[T]]) -> Callable[P, Awaitable[T]]:
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.25.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(root / "python/tests/test_redis_integration.py"),
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
- id_arg="id",
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
- id_arg="id",
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
- id_arg="id",
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
- "baseline",
70
- "inherited_keyword",
71
- "configured_keyword",
72
- "inherited_collect_only",
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
- assert not list(coverage.glob("*.lcov"))
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
- report = (coverage / f"{backend}.lcov").read_text()
148
- assert "SF:" in report and "stale report" not in report
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