server-decorator 2.0.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 (40) hide show
  1. server_decorator-2.0.0/.gitignore +29 -0
  2. server_decorator-2.0.0/PKG-INFO +109 -0
  3. server_decorator-2.0.0/README.md +67 -0
  4. server_decorator-2.0.0/pyproject.toml +84 -0
  5. server_decorator-2.0.0/scripts/check.sh +19 -0
  6. server_decorator-2.0.0/src/server_decorator/__init__.py +61 -0
  7. server_decorator-2.0.0/src/server_decorator/_contract.py +3 -0
  8. server_decorator-2.0.0/src/server_decorator/_version.py +1 -0
  9. server_decorator-2.0.0/src/server_decorator/decorators/__init__.py +1 -0
  10. server_decorator-2.0.0/src/server_decorator/decorators/_cache_key.py +35 -0
  11. server_decorator-2.0.0/src/server_decorator/decorators/_class_apply.py +67 -0
  12. server_decorator-2.0.0/src/server_decorator/decorators/_qualname.py +11 -0
  13. server_decorator-2.0.0/src/server_decorator/decorators/cache.py +92 -0
  14. server_decorator-2.0.0/src/server_decorator/decorators/emit_on_success.py +171 -0
  15. server_decorator-2.0.0/src/server_decorator/decorators/tracking.py +78 -0
  16. server_decorator-2.0.0/src/server_decorator/emitter.py +30 -0
  17. server_decorator-2.0.0/src/server_decorator/logger.py +38 -0
  18. server_decorator-2.0.0/src/server_decorator/queue/__init__.py +1 -0
  19. server_decorator-2.0.0/src/server_decorator/queue/adapter.py +31 -0
  20. server_decorator-2.0.0/src/server_decorator/queue/adapters/__init__.py +1 -0
  21. server_decorator-2.0.0/src/server_decorator/queue/adapters/inmemory.py +20 -0
  22. server_decorator-2.0.0/src/server_decorator/queue/adapters/kafka_adapter.py +26 -0
  23. server_decorator-2.0.0/src/server_decorator/queue/adapters/noop.py +10 -0
  24. server_decorator-2.0.0/src/server_decorator/queue/adapters/rabbitmq_adapter.py +38 -0
  25. server_decorator-2.0.0/src/server_decorator/queue/adapters/redis_adapter.py +22 -0
  26. server_decorator-2.0.0/src/server_decorator/queue/message.py +21 -0
  27. server_decorator-2.0.0/src/server_decorator/queue/registry.py +64 -0
  28. server_decorator-2.0.0/src/server_decorator/tracker.py +26 -0
  29. server_decorator-2.0.0/tests/__init__.py +0 -0
  30. server_decorator-2.0.0/tests/integration/__init__.py +0 -0
  31. server_decorator-2.0.0/tests/integration/test_emit_kafka.py +54 -0
  32. server_decorator-2.0.0/tests/integration/test_emit_rabbitmq.py +62 -0
  33. server_decorator-2.0.0/tests/integration/test_emit_redis.py +89 -0
  34. server_decorator-2.0.0/tests/unit/__init__.py +0 -0
  35. server_decorator-2.0.0/tests/unit/test_cache.py +147 -0
  36. server_decorator-2.0.0/tests/unit/test_cache_key_parity.py +34 -0
  37. server_decorator-2.0.0/tests/unit/test_emit_on_success.py +138 -0
  38. server_decorator-2.0.0/tests/unit/test_smoke.py +15 -0
  39. server_decorator-2.0.0/tests/unit/test_tracking.py +157 -0
  40. server_decorator-2.0.0/uv.lock +1568 -0
@@ -0,0 +1,29 @@
1
+ .npmrc
2
+ dist
3
+ node_modules
4
+
5
+ # GitHub Actions
6
+ .env
7
+ *.log
8
+
9
+ # Package managers
10
+ npm-debug.log*
11
+ yarn-debug.log*
12
+ yarn-error.log*
13
+ package-lock.json # Use pnpm-lock.yaml instead
14
+ yarn.lock # Use pnpm-lock.yaml instead
15
+
16
+ # iCloud-style duplicate files (D-D)
17
+ * 2
18
+ * 2.*
19
+ * 3
20
+ * 3.*
21
+
22
+ # Python (added during polyglot migration)
23
+ __pycache__/
24
+ *.py[cod]
25
+ .venv/
26
+ *.egg-info/
27
+ .pytest_cache/
28
+ .mypy_cache/
29
+ .ruff_cache/
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: server-decorator
3
+ Version: 2.0.0
4
+ Summary: Server-side decorators for caching, tracking, and queue emission. Polyglot sibling of the Node `server-decorator` npm package.
5
+ Project-URL: Homepage, https://github.com/your-username/server-decorator
6
+ Project-URL: Repository, https://github.com/your-username/server-decorator
7
+ Project-URL: Issues, https://github.com/your-username/server-decorator/issues
8
+ Author: server-decorator contributors
9
+ License: MIT
10
+ Keywords: caching,decorators,kafka,queue,rabbitmq,redis,tracking
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: all
21
+ Requires-Dist: aio-pika>=9.4; extra == 'all'
22
+ Requires-Dist: aiokafka>=0.11; extra == 'all'
23
+ Requires-Dist: redis>=5.0; extra == 'all'
24
+ Provides-Extra: dev
25
+ Requires-Dist: aio-pika>=9.4; extra == 'dev'
26
+ Requires-Dist: aiokafka>=0.11; extra == 'dev'
27
+ Requires-Dist: jsonschema>=4.21; extra == 'dev'
28
+ Requires-Dist: mypy>=1.10; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
30
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
31
+ Requires-Dist: pytest>=8; extra == 'dev'
32
+ Requires-Dist: redis>=5.0; extra == 'dev'
33
+ Requires-Dist: ruff>=0.5; extra == 'dev'
34
+ Requires-Dist: testcontainers[kafka,rabbitmq,redis]>=4.7; extra == 'dev'
35
+ Provides-Extra: kafka
36
+ Requires-Dist: aiokafka>=0.11; extra == 'kafka'
37
+ Provides-Extra: rabbitmq
38
+ Requires-Dist: aio-pika>=9.4; extra == 'rabbitmq'
39
+ Provides-Extra: redis
40
+ Requires-Dist: redis>=5.0; extra == 'redis'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # server-decorator (Python)
44
+
45
+ Server-side decorators for caching, tracking, and queue emission. Polyglot sibling of the Node `server-decorator` npm package — both share the wire-format contracts in [`../contracts`](../contracts).
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install server-decorator # core
51
+ pip install server-decorator[redis] # + redis adapter
52
+ pip install server-decorator[rabbitmq] # + aio-pika adapter
53
+ pip install server-decorator[kafka] # + aiokafka adapter
54
+ pip install server-decorator[all] # all adapters
55
+ ```
56
+
57
+ Requires Python 3.10+. `asyncio` only — no `trio`/`anyio` (Decision #10).
58
+
59
+ ## Quick start
60
+
61
+ ### Method form
62
+
63
+ ```python
64
+ from server_decorator import tracking, cache, emit_on_success, CACHE_MISS
65
+
66
+ class InMemoryCache:
67
+ def __init__(self): self._d = {}
68
+ def get(self, key): return self._d.get(key, CACHE_MISS)
69
+ def set(self, key, val, ttl): self._d[key] = val
70
+
71
+ class OrderService:
72
+ @tracking
73
+ @cache(InMemoryCache(), ttl=300)
74
+ @emit_on_success(use_events=True)
75
+ async def create_order(self, customer_id: str, total: float) -> dict:
76
+ return {"customer_id": customer_id, "total": total, "status": "pending"}
77
+ ```
78
+
79
+ ### Class form (auto-wrap every public method)
80
+
81
+ ```python
82
+ from server_decorator import tracking_class
83
+
84
+ @tracking_class()
85
+ class OrderService:
86
+ async def create_order(self, ...): ... # auto-wrapped
87
+ async def cancel_order(self, ...): ... # auto-wrapped
88
+ def _internal(self): ... # SKIPPED (leading underscore)
89
+ ```
90
+
91
+ `tracking_class`, `cache_class`, and `emit_on_success_class` accept `include`, `exclude`, and `include_private` to override the default predicates.
92
+
93
+ ## Sync vs async
94
+
95
+ Method-level decorators auto-detect coroutines via `inspect.iscoroutinefunction` and wrap accordingly (Decision #5). For `@emit_on_success(use_queue=True)` on a sync method called outside an event loop, the side effect runs on a single-worker `ThreadPoolExecutor` so the caller doesn't block (D-C). To drain in-flight emits at process exit:
96
+
97
+ ```python
98
+ import atexit
99
+ from server_decorator.decorators.emit_on_success import _EMIT_EXECUTOR
100
+ atexit.register(_EMIT_EXECUTOR.shutdown, wait=True)
101
+ ```
102
+
103
+ ## Caching `None` / falsy values
104
+
105
+ `CacheAdapter.get` returns `CACHE_MISS` (a module-level sentinel) when a key is absent — `None`/`0`/`""`/`False` are valid cached values. See [`../contracts/rules/cache-adapter.md`](../contracts/rules/cache-adapter.md).
106
+
107
+ ## License
108
+
109
+ MIT.
@@ -0,0 +1,67 @@
1
+ # server-decorator (Python)
2
+
3
+ Server-side decorators for caching, tracking, and queue emission. Polyglot sibling of the Node `server-decorator` npm package — both share the wire-format contracts in [`../contracts`](../contracts).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install server-decorator # core
9
+ pip install server-decorator[redis] # + redis adapter
10
+ pip install server-decorator[rabbitmq] # + aio-pika adapter
11
+ pip install server-decorator[kafka] # + aiokafka adapter
12
+ pip install server-decorator[all] # all adapters
13
+ ```
14
+
15
+ Requires Python 3.10+. `asyncio` only — no `trio`/`anyio` (Decision #10).
16
+
17
+ ## Quick start
18
+
19
+ ### Method form
20
+
21
+ ```python
22
+ from server_decorator import tracking, cache, emit_on_success, CACHE_MISS
23
+
24
+ class InMemoryCache:
25
+ def __init__(self): self._d = {}
26
+ def get(self, key): return self._d.get(key, CACHE_MISS)
27
+ def set(self, key, val, ttl): self._d[key] = val
28
+
29
+ class OrderService:
30
+ @tracking
31
+ @cache(InMemoryCache(), ttl=300)
32
+ @emit_on_success(use_events=True)
33
+ async def create_order(self, customer_id: str, total: float) -> dict:
34
+ return {"customer_id": customer_id, "total": total, "status": "pending"}
35
+ ```
36
+
37
+ ### Class form (auto-wrap every public method)
38
+
39
+ ```python
40
+ from server_decorator import tracking_class
41
+
42
+ @tracking_class()
43
+ class OrderService:
44
+ async def create_order(self, ...): ... # auto-wrapped
45
+ async def cancel_order(self, ...): ... # auto-wrapped
46
+ def _internal(self): ... # SKIPPED (leading underscore)
47
+ ```
48
+
49
+ `tracking_class`, `cache_class`, and `emit_on_success_class` accept `include`, `exclude`, and `include_private` to override the default predicates.
50
+
51
+ ## Sync vs async
52
+
53
+ Method-level decorators auto-detect coroutines via `inspect.iscoroutinefunction` and wrap accordingly (Decision #5). For `@emit_on_success(use_queue=True)` on a sync method called outside an event loop, the side effect runs on a single-worker `ThreadPoolExecutor` so the caller doesn't block (D-C). To drain in-flight emits at process exit:
54
+
55
+ ```python
56
+ import atexit
57
+ from server_decorator.decorators.emit_on_success import _EMIT_EXECUTOR
58
+ atexit.register(_EMIT_EXECUTOR.shutdown, wait=True)
59
+ ```
60
+
61
+ ## Caching `None` / falsy values
62
+
63
+ `CacheAdapter.get` returns `CACHE_MISS` (a module-level sentinel) when a key is absent — `None`/`0`/`""`/`False` are valid cached values. See [`../contracts/rules/cache-adapter.md`](../contracts/rules/cache-adapter.md).
64
+
65
+ ## License
66
+
67
+ MIT.
@@ -0,0 +1,84 @@
1
+ [project]
2
+ name = "server-decorator"
3
+ version = "2.0.0"
4
+ description = "Server-side decorators for caching, tracking, and queue emission. Polyglot sibling of the Node `server-decorator` npm package."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = {text = "MIT"}
8
+ authors = [{name = "server-decorator contributors"}]
9
+ keywords = ["decorators", "caching", "tracking", "queue", "redis", "rabbitmq", "kafka"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.optional-dependencies]
23
+ redis = ["redis>=5.0"]
24
+ rabbitmq = ["aio-pika>=9.4"]
25
+ kafka = ["aiokafka>=0.11"]
26
+ all = ["redis>=5.0", "aio-pika>=9.4", "aiokafka>=0.11"]
27
+ dev = [
28
+ "pytest>=8",
29
+ "pytest-asyncio>=0.23",
30
+ "pytest-cov>=5",
31
+ "ruff>=0.5",
32
+ "mypy>=1.10",
33
+ "jsonschema>=4.21",
34
+ "redis>=5.0",
35
+ "aio-pika>=9.4",
36
+ "aiokafka>=0.11",
37
+ "testcontainers[redis,rabbitmq,kafka]>=4.7",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/your-username/server-decorator"
42
+ Repository = "https://github.com/your-username/server-decorator"
43
+ Issues = "https://github.com/your-username/server-decorator/issues"
44
+
45
+ [build-system]
46
+ requires = ["hatchling"]
47
+ build-backend = "hatchling.build"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/server_decorator"]
51
+
52
+ [tool.ruff]
53
+ line-length = 100
54
+ target-version = "py310"
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "I", "B", "UP", "N", "ASYNC", "RUF"]
58
+ # RUF022: keep our grouped/commented __all__ ordering (section comments get destroyed
59
+ # by the isort-style sort).
60
+ ignore = ["RUF022"]
61
+
62
+ [tool.ruff.lint.per-file-ignores]
63
+ "tests/**/*.py" = ["S101", "ANN"]
64
+
65
+ [tool.mypy]
66
+ python_version = "3.10"
67
+ strict = true
68
+ files = ["src/server_decorator"]
69
+
70
+ # Optional adapter dependencies don't ship `py.typed`. Treat them as untyped so
71
+ # strict-mode still passes; the adapter wrappers keep the public-facing types
72
+ # precise on the server-decorator side.
73
+ [[tool.mypy.overrides]]
74
+ module = ["aiokafka.*", "aio_pika.*", "redis.*", "redis.asyncio"]
75
+ # Make the optional adapter deps appear as Any. They don't ship `py.typed`,
76
+ # and we don't want to vendor stubs for one thin wrapper layer.
77
+ ignore_missing_imports = true
78
+
79
+ [tool.pytest.ini_options]
80
+ asyncio_mode = "auto"
81
+ testpaths = ["tests"]
82
+ markers = [
83
+ "integration: requires Docker / testcontainers",
84
+ ]
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # python/scripts/check.sh — lint + type-check + unit tests.
3
+ # Assumes `uv` is installed; `pip install uv` if not.
4
+ set -euo pipefail
5
+
6
+ cd "$(dirname "$0")/.."
7
+
8
+ if command -v uv >/dev/null 2>&1; then
9
+ uv sync --extra dev >/dev/null
10
+ RUN="uv run"
11
+ else
12
+ echo "warning: uv not found; falling back to plain python+pytest. Install uv for the full dev experience." >&2
13
+ RUN=""
14
+ fi
15
+
16
+ $RUN ruff check src tests
17
+ $RUN ruff format --check src tests
18
+ $RUN mypy src
19
+ $RUN pytest -q tests/unit
@@ -0,0 +1,61 @@
1
+ """server-decorator (Python) — public API barrel.
2
+
3
+ Mirrors `node/src/index.ts`. Method-form decorators behave identically to the Node
4
+ versions on the same inputs (per `contracts/`). Class-form decorators (Decision #4)
5
+ auto-wrap every qualifying method.
6
+ """
7
+
8
+ from server_decorator._contract import CONTRACT_VERSION
9
+ from server_decorator._version import __version__
10
+ from server_decorator.decorators.cache import CACHE_MISS, cache, cache_class
11
+ from server_decorator.decorators.emit_on_success import emit_on_success, emit_on_success_class
12
+ from server_decorator.decorators.tracking import tracking, tracking_class
13
+ from server_decorator.emitter import global_emitter
14
+ from server_decorator.logger import Logger, global_logger, set_global_logger
15
+ from server_decorator.queue.adapter import (
16
+ QueueAdapter,
17
+ global_queue,
18
+ has_global_queue,
19
+ set_global_queue,
20
+ )
21
+ from server_decorator.queue.adapters.inmemory import InMemoryQueueAdapter
22
+ from server_decorator.queue.adapters.noop import NoOpQueueAdapter
23
+ from server_decorator.queue.message import QueueMessage
24
+ from server_decorator.queue.registry import QueueConfig, QueueRegistry, global_queue_registry
25
+ from server_decorator.tracker import Tracker, global_tracker, set_global_tracker
26
+
27
+ __all__ = [
28
+ "__version__",
29
+ "CONTRACT_VERSION",
30
+ # method-form decorators (mirror Node)
31
+ "cache",
32
+ "tracking",
33
+ "emit_on_success",
34
+ # class-form decorators (Decision #4)
35
+ "cache_class",
36
+ "tracking_class",
37
+ "emit_on_success_class",
38
+ # cache miss sentinel (D-B)
39
+ "CACHE_MISS",
40
+ # logger
41
+ "Logger",
42
+ "global_logger",
43
+ "set_global_logger",
44
+ # tracker
45
+ "Tracker",
46
+ "global_tracker",
47
+ "set_global_tracker",
48
+ # in-process emitter
49
+ "global_emitter",
50
+ # queue
51
+ "QueueMessage",
52
+ "QueueAdapter",
53
+ "global_queue",
54
+ "set_global_queue",
55
+ "has_global_queue",
56
+ "QueueRegistry",
57
+ "global_queue_registry",
58
+ "QueueConfig",
59
+ "InMemoryQueueAdapter",
60
+ "NoOpQueueAdapter",
61
+ ]
@@ -0,0 +1,3 @@
1
+ """Contract version this package targets. Mirrors contracts/VERSION."""
2
+
3
+ CONTRACT_VERSION = "1.0.0"
@@ -0,0 +1 @@
1
+ __version__ = "2.0.0"
@@ -0,0 +1 @@
1
+ """Decorators subsystem."""
@@ -0,0 +1,35 @@
1
+ """Cache key algorithm. Spec: contracts/rules/cache-key.md (v1.0.0).
2
+
3
+ Both Python and Node implement this from the same spec; Node's port lives at
4
+ `node/src/decorators/_cache_key.ts`. Parity tests load
5
+ `contracts/fixtures/cache-key.examples.json`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from typing import Any
13
+
14
+ MAX_KEY_BYTES = 250
15
+
16
+
17
+ def _repr_arg(x: Any) -> str:
18
+ return json.dumps(x, sort_keys=True, default=str, separators=(",", ":"))
19
+
20
+
21
+ def build_cache_key(
22
+ qualname: str,
23
+ args: tuple[Any, ...] | list[Any],
24
+ kwargs: dict[str, Any],
25
+ ) -> str:
26
+ arg_pieces = [_repr_arg(a) for a in args]
27
+ if kwargs:
28
+ for k in sorted(kwargs.keys()):
29
+ arg_pieces.append(f"{k}={_repr_arg(kwargs[k])}")
30
+ arg_repr = ",".join(arg_pieces)
31
+ raw = f"{qualname}({arg_repr})"
32
+ if len(raw.encode("utf-8")) <= MAX_KEY_BYTES:
33
+ return raw
34
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
35
+ return f"{qualname}(sha256:{digest})"
@@ -0,0 +1,67 @@
1
+ """Class-decorator helper: walk vars(cls), apply a method-decorator to each qualifying member.
2
+
3
+ Decision #4. The marker `__server_decorator_wrapped_by__` prevents double-wrapping when
4
+ both class-level and method-level decoration apply (method-level wins).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import inspect
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ _MARKER = "__server_decorator_wrapped_by__"
14
+
15
+
16
+ def _is_wrapped_by(fn: Callable[..., Any], name: str) -> bool:
17
+ return name in getattr(fn, _MARKER, ())
18
+
19
+
20
+ def _mark_wrapped(wrapper: Callable[..., Any], name: str) -> None:
21
+ existing: tuple[str, ...] = getattr(wrapper, _MARKER, ())
22
+ object.__setattr__(wrapper, _MARKER, (*existing, name))
23
+
24
+
25
+ def apply_to_class(
26
+ cls: type,
27
+ method_decorator: Callable[[Callable[..., Any]], Callable[..., Any]],
28
+ *,
29
+ decorator_name: str,
30
+ include: tuple[str, ...] = (),
31
+ exclude: tuple[str, ...] = (),
32
+ include_private: bool = False,
33
+ wrap_properties: bool = False,
34
+ ) -> type:
35
+ for attr_name, attr in list(vars(cls).items()):
36
+ if attr_name in exclude:
37
+ continue
38
+ if attr_name.startswith("_") and attr_name not in include and not include_private:
39
+ continue
40
+
41
+ if isinstance(attr, classmethod):
42
+ inner = attr.__func__
43
+ if _is_wrapped_by(inner, decorator_name):
44
+ continue
45
+ wrapped = method_decorator(inner)
46
+ _mark_wrapped(wrapped, decorator_name)
47
+ setattr(cls, attr_name, classmethod(wrapped))
48
+ elif isinstance(attr, staticmethod):
49
+ inner = attr.__func__
50
+ if _is_wrapped_by(inner, decorator_name):
51
+ continue
52
+ wrapped = method_decorator(inner)
53
+ _mark_wrapped(wrapped, decorator_name)
54
+ setattr(cls, attr_name, staticmethod(wrapped))
55
+ elif isinstance(attr, property):
56
+ if not wrap_properties:
57
+ continue
58
+ # v1.1+: wrap fget/fset; deferred per N1.
59
+ continue
60
+ elif inspect.isfunction(attr) or inspect.iscoroutinefunction(attr):
61
+ if _is_wrapped_by(attr, decorator_name):
62
+ continue
63
+ wrapped = method_decorator(attr)
64
+ _mark_wrapped(wrapped, decorator_name)
65
+ setattr(cls, attr_name, wrapped)
66
+ # else: nested classes, plain attrs — ignored.
67
+ return cls
@@ -0,0 +1,11 @@
1
+ """Qualname normaliser. Drops `<locals>` markers and keeps the last two segments
2
+ so test-defined classes / nested classes match Node's `ClassName.methodName`."""
3
+
4
+ from __future__ import annotations
5
+
6
+
7
+ def shorten_qualname(qualname: str) -> str:
8
+ parts = [p for p in qualname.split(".") if p != "<locals>"]
9
+ if len(parts) >= 2:
10
+ return ".".join(parts[-2:])
11
+ return qualname
@@ -0,0 +1,92 @@
1
+ """@cache decorator (method form) + cache_class (class form). Decisions #6 + D-B.
2
+
3
+ The `CACHE_MISS` sentinel (D-B) lets users cache None/0/""/False as legitimate values:
4
+ adapters return CACHE_MISS for absent keys; the decorator uses identity comparison
5
+ (`cached is not CACHE_MISS`) to distinguish hit from miss.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import inspect
12
+ from collections.abc import Awaitable, Callable
13
+ from typing import Any, Protocol, TypeVar, cast
14
+
15
+ from server_decorator.decorators._cache_key import build_cache_key
16
+ from server_decorator.decorators._class_apply import _mark_wrapped, apply_to_class
17
+ from server_decorator.decorators._qualname import shorten_qualname
18
+
19
+ F = TypeVar("F", bound=Callable[..., Any])
20
+
21
+ # D-B: distinct sentinel so adapters can signal "absent key" without colliding with
22
+ # legitimately-cached None/0/""/False values. Re-exported from the package barrel.
23
+ CACHE_MISS: Any = object()
24
+
25
+
26
+ class CacheAdapter(Protocol):
27
+ def get(self, key: str) -> Any | Awaitable[Any]: ...
28
+ def set(self, key: str, value: Any, ttl: int) -> Any | Awaitable[Any]: ...
29
+
30
+
31
+ def cache(adapter: CacheAdapter, ttl: int) -> Callable[[F], F]:
32
+ def decorator(fn: F) -> F:
33
+ qualname = shorten_qualname(fn.__qualname__)
34
+
35
+ if inspect.iscoroutinefunction(fn):
36
+
37
+ @functools.wraps(fn)
38
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
39
+ key = build_cache_key(qualname, args, kwargs)
40
+ cached = adapter.get(key)
41
+ if inspect.isawaitable(cached):
42
+ cached = await cached
43
+ if cached is not CACHE_MISS:
44
+ return cached
45
+ result = await fn(*args, **kwargs)
46
+ set_result = adapter.set(key, result, ttl)
47
+ if inspect.isawaitable(set_result):
48
+ await set_result
49
+ return result
50
+
51
+ _mark_wrapped(async_wrapper, "cache")
52
+ return cast(F, async_wrapper)
53
+
54
+ @functools.wraps(fn)
55
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
56
+ key = build_cache_key(qualname, args, kwargs)
57
+ cached = adapter.get(key)
58
+ if cached is not CACHE_MISS:
59
+ return cached
60
+ result = fn(*args, **kwargs)
61
+ adapter.set(key, result, ttl)
62
+ return result
63
+
64
+ _mark_wrapped(sync_wrapper, "cache")
65
+ return cast(F, sync_wrapper)
66
+
67
+ return decorator
68
+
69
+
70
+ def cache_class(
71
+ adapter: CacheAdapter,
72
+ ttl: int,
73
+ *,
74
+ include: tuple[str, ...] = (),
75
+ exclude: tuple[str, ...] = (),
76
+ include_private: bool = False,
77
+ ) -> Callable[[type], type]:
78
+ """Class form: auto-apply @cache to every public method. Decision #4."""
79
+
80
+ method_dec = cache(adapter, ttl)
81
+
82
+ def wrap(cls: type) -> type:
83
+ return apply_to_class(
84
+ cls,
85
+ method_dec,
86
+ decorator_name="cache",
87
+ include=include,
88
+ exclude=exclude,
89
+ include_private=include_private,
90
+ )
91
+
92
+ return wrap