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.
- server_decorator-2.0.0/.gitignore +29 -0
- server_decorator-2.0.0/PKG-INFO +109 -0
- server_decorator-2.0.0/README.md +67 -0
- server_decorator-2.0.0/pyproject.toml +84 -0
- server_decorator-2.0.0/scripts/check.sh +19 -0
- server_decorator-2.0.0/src/server_decorator/__init__.py +61 -0
- server_decorator-2.0.0/src/server_decorator/_contract.py +3 -0
- server_decorator-2.0.0/src/server_decorator/_version.py +1 -0
- server_decorator-2.0.0/src/server_decorator/decorators/__init__.py +1 -0
- server_decorator-2.0.0/src/server_decorator/decorators/_cache_key.py +35 -0
- server_decorator-2.0.0/src/server_decorator/decorators/_class_apply.py +67 -0
- server_decorator-2.0.0/src/server_decorator/decorators/_qualname.py +11 -0
- server_decorator-2.0.0/src/server_decorator/decorators/cache.py +92 -0
- server_decorator-2.0.0/src/server_decorator/decorators/emit_on_success.py +171 -0
- server_decorator-2.0.0/src/server_decorator/decorators/tracking.py +78 -0
- server_decorator-2.0.0/src/server_decorator/emitter.py +30 -0
- server_decorator-2.0.0/src/server_decorator/logger.py +38 -0
- server_decorator-2.0.0/src/server_decorator/queue/__init__.py +1 -0
- server_decorator-2.0.0/src/server_decorator/queue/adapter.py +31 -0
- server_decorator-2.0.0/src/server_decorator/queue/adapters/__init__.py +1 -0
- server_decorator-2.0.0/src/server_decorator/queue/adapters/inmemory.py +20 -0
- server_decorator-2.0.0/src/server_decorator/queue/adapters/kafka_adapter.py +26 -0
- server_decorator-2.0.0/src/server_decorator/queue/adapters/noop.py +10 -0
- server_decorator-2.0.0/src/server_decorator/queue/adapters/rabbitmq_adapter.py +38 -0
- server_decorator-2.0.0/src/server_decorator/queue/adapters/redis_adapter.py +22 -0
- server_decorator-2.0.0/src/server_decorator/queue/message.py +21 -0
- server_decorator-2.0.0/src/server_decorator/queue/registry.py +64 -0
- server_decorator-2.0.0/src/server_decorator/tracker.py +26 -0
- server_decorator-2.0.0/tests/__init__.py +0 -0
- server_decorator-2.0.0/tests/integration/__init__.py +0 -0
- server_decorator-2.0.0/tests/integration/test_emit_kafka.py +54 -0
- server_decorator-2.0.0/tests/integration/test_emit_rabbitmq.py +62 -0
- server_decorator-2.0.0/tests/integration/test_emit_redis.py +89 -0
- server_decorator-2.0.0/tests/unit/__init__.py +0 -0
- server_decorator-2.0.0/tests/unit/test_cache.py +147 -0
- server_decorator-2.0.0/tests/unit/test_cache_key_parity.py +34 -0
- server_decorator-2.0.0/tests/unit/test_emit_on_success.py +138 -0
- server_decorator-2.0.0/tests/unit/test_smoke.py +15 -0
- server_decorator-2.0.0/tests/unit/test_tracking.py +157 -0
- 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 @@
|
|
|
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
|