server-decorator 2.0.0__py3-none-any.whl
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/__init__.py +61 -0
- server_decorator/_contract.py +3 -0
- server_decorator/_version.py +1 -0
- server_decorator/decorators/__init__.py +1 -0
- server_decorator/decorators/_cache_key.py +35 -0
- server_decorator/decorators/_class_apply.py +67 -0
- server_decorator/decorators/_qualname.py +11 -0
- server_decorator/decorators/cache.py +92 -0
- server_decorator/decorators/emit_on_success.py +171 -0
- server_decorator/decorators/tracking.py +78 -0
- server_decorator/emitter.py +30 -0
- server_decorator/logger.py +38 -0
- server_decorator/queue/__init__.py +1 -0
- server_decorator/queue/adapter.py +31 -0
- server_decorator/queue/adapters/__init__.py +1 -0
- server_decorator/queue/adapters/inmemory.py +20 -0
- server_decorator/queue/adapters/kafka_adapter.py +26 -0
- server_decorator/queue/adapters/noop.py +10 -0
- server_decorator/queue/adapters/rabbitmq_adapter.py +38 -0
- server_decorator/queue/adapters/redis_adapter.py +22 -0
- server_decorator/queue/message.py +21 -0
- server_decorator/queue/registry.py +64 -0
- server_decorator/tracker.py +26 -0
- server_decorator-2.0.0.dist-info/METADATA +109 -0
- server_decorator-2.0.0.dist-info/RECORD +26 -0
- server_decorator-2.0.0.dist-info/WHEEL +4 -0
|
@@ -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
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""@emit_on_success decorator (method form) + emit_on_success_class. Decisions #7 + D-C.
|
|
2
|
+
|
|
3
|
+
When `use_queue=True` is set on a sync method called outside an event loop, the side
|
|
4
|
+
effect runs on a single-worker `_EMIT_EXECUTOR` so the caller doesn't block (D-C).
|
|
5
|
+
Register `atexit(_EMIT_EXECUTOR.shutdown, wait=True)` for delivery on shutdown.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import concurrent.futures
|
|
12
|
+
import functools
|
|
13
|
+
import inspect
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, TypeVar, cast
|
|
18
|
+
|
|
19
|
+
from server_decorator import emitter as _emitter_mod
|
|
20
|
+
from server_decorator import logger as _logger_mod
|
|
21
|
+
from server_decorator.decorators._class_apply import _mark_wrapped, apply_to_class
|
|
22
|
+
from server_decorator.decorators._qualname import shorten_qualname
|
|
23
|
+
from server_decorator.queue import registry as _registry_mod
|
|
24
|
+
from server_decorator.queue.message import QueueMessage
|
|
25
|
+
|
|
26
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
27
|
+
|
|
28
|
+
# D-C: single-worker executor for sync-method fire-and-forget. I/O-bound and
|
|
29
|
+
# ordering matters, so one worker is fine. Drained on shutdown via atexit.
|
|
30
|
+
_EMIT_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
|
31
|
+
max_workers=1, thread_name_prefix="server-decorator-emit"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Strong references to in-flight create_task() futures so the GC doesn't drop them
|
|
35
|
+
# mid-flight (RUF006). Tasks self-remove via add_done_callback.
|
|
36
|
+
_BACKGROUND_TASKS: set[asyncio.Task[None]] = set()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class EmitOnSuccessOptions:
|
|
41
|
+
transform: Callable[[Any], Any] | None = None
|
|
42
|
+
use_events: bool = True
|
|
43
|
+
use_queue: bool = False
|
|
44
|
+
queue: str | None = None
|
|
45
|
+
topic: str | None = None
|
|
46
|
+
queue_metadata: dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
event_name: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def emit_on_success(
|
|
51
|
+
*,
|
|
52
|
+
transform: Callable[[Any], Any] | None = None,
|
|
53
|
+
use_events: bool = True,
|
|
54
|
+
use_queue: bool = False,
|
|
55
|
+
queue: str | None = None,
|
|
56
|
+
topic: str | None = None,
|
|
57
|
+
queue_metadata: dict[str, Any] | None = None,
|
|
58
|
+
event_name: str | None = None,
|
|
59
|
+
) -> Callable[[F], F]:
|
|
60
|
+
opts = EmitOnSuccessOptions(
|
|
61
|
+
transform=transform,
|
|
62
|
+
use_events=use_events,
|
|
63
|
+
use_queue=use_queue,
|
|
64
|
+
queue=queue,
|
|
65
|
+
topic=topic,
|
|
66
|
+
queue_metadata=queue_metadata or {},
|
|
67
|
+
event_name=event_name,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def decorator(fn: F) -> F:
|
|
71
|
+
qualname = shorten_qualname(fn.__qualname__)
|
|
72
|
+
class_name, _, method_name = qualname.rpartition(".")
|
|
73
|
+
resolved_event = opts.event_name or qualname
|
|
74
|
+
|
|
75
|
+
async def _on_success(result: Any) -> None:
|
|
76
|
+
payload = opts.transform(result) if opts.transform else result
|
|
77
|
+
if opts.use_events:
|
|
78
|
+
try:
|
|
79
|
+
_emitter_mod.global_emitter.emit(resolved_event, payload)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
_logger_mod.global_logger.error(f"Failed to emit event {resolved_event}: {e!r}")
|
|
82
|
+
if opts.use_queue:
|
|
83
|
+
try:
|
|
84
|
+
msg: QueueMessage = {
|
|
85
|
+
"eventName": resolved_event,
|
|
86
|
+
"payload": payload,
|
|
87
|
+
"timestamp": int(time.time() * 1000),
|
|
88
|
+
"metadata": {
|
|
89
|
+
"className": class_name,
|
|
90
|
+
"methodName": method_name,
|
|
91
|
+
"topic": opts.topic,
|
|
92
|
+
**opts.queue_metadata, # type: ignore[typeddict-item]
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
if opts.queue or opts.topic:
|
|
96
|
+
auto_topic = opts.topic or qualname
|
|
97
|
+
await _registry_mod.global_queue_registry.publish(
|
|
98
|
+
msg, opts.queue, auto_topic
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
await _registry_mod.global_queue_registry.publish(msg)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
_logger_mod.global_logger.error(f"Failed to publish to queue: {e!r}")
|
|
104
|
+
|
|
105
|
+
if inspect.iscoroutinefunction(fn):
|
|
106
|
+
|
|
107
|
+
@functools.wraps(fn)
|
|
108
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
109
|
+
result = await fn(*args, **kwargs)
|
|
110
|
+
await _on_success(result)
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
_mark_wrapped(async_wrapper, "emit_on_success")
|
|
114
|
+
return cast(F, async_wrapper)
|
|
115
|
+
|
|
116
|
+
@functools.wraps(fn)
|
|
117
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
118
|
+
result = fn(*args, **kwargs)
|
|
119
|
+
try:
|
|
120
|
+
loop = asyncio.get_running_loop()
|
|
121
|
+
# Tracked in _BACKGROUND_TASKS to keep a strong reference (RUF006).
|
|
122
|
+
task = loop.create_task(_on_success(result))
|
|
123
|
+
_BACKGROUND_TASKS.add(task)
|
|
124
|
+
task.add_done_callback(_BACKGROUND_TASKS.discard)
|
|
125
|
+
except RuntimeError:
|
|
126
|
+
# D-C: no running loop — submit to executor so caller doesn't block.
|
|
127
|
+
_EMIT_EXECUTOR.submit(asyncio.run, _on_success(result))
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
_mark_wrapped(sync_wrapper, "emit_on_success")
|
|
131
|
+
return cast(F, sync_wrapper)
|
|
132
|
+
|
|
133
|
+
return decorator
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def emit_on_success_class(
|
|
137
|
+
*,
|
|
138
|
+
transform: Callable[[Any], Any] | None = None,
|
|
139
|
+
use_events: bool = True,
|
|
140
|
+
use_queue: bool = False,
|
|
141
|
+
queue: str | None = None,
|
|
142
|
+
topic: str | None = None,
|
|
143
|
+
queue_metadata: dict[str, Any] | None = None,
|
|
144
|
+
event_name: str | None = None,
|
|
145
|
+
include: tuple[str, ...] = (),
|
|
146
|
+
exclude: tuple[str, ...] = (),
|
|
147
|
+
include_private: bool = False,
|
|
148
|
+
) -> Callable[[type], type]:
|
|
149
|
+
"""Class form: auto-apply @emit_on_success to every public method. Decision #4."""
|
|
150
|
+
|
|
151
|
+
method_dec = emit_on_success(
|
|
152
|
+
transform=transform,
|
|
153
|
+
use_events=use_events,
|
|
154
|
+
use_queue=use_queue,
|
|
155
|
+
queue=queue,
|
|
156
|
+
topic=topic,
|
|
157
|
+
queue_metadata=queue_metadata,
|
|
158
|
+
event_name=event_name,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def wrap(cls: type) -> type:
|
|
162
|
+
return apply_to_class(
|
|
163
|
+
cls,
|
|
164
|
+
method_dec,
|
|
165
|
+
decorator_name="emit_on_success",
|
|
166
|
+
include=include,
|
|
167
|
+
exclude=exclude,
|
|
168
|
+
include_private=include_private,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return wrap
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""@tracking decorator (method form) + tracking_class (class form). Decision #5."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any, TypeVar, cast
|
|
10
|
+
|
|
11
|
+
from server_decorator import logger as _logger_mod
|
|
12
|
+
from server_decorator import tracker as _tracker_mod
|
|
13
|
+
from server_decorator.decorators._class_apply import _mark_wrapped, apply_to_class
|
|
14
|
+
from server_decorator.decorators._qualname import shorten_qualname
|
|
15
|
+
|
|
16
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def tracking(fn: F) -> F:
|
|
20
|
+
"""Track latency and error rate. Mirrors Node `@tracking`. Re-raises any exception."""
|
|
21
|
+
qualname = shorten_qualname(fn.__qualname__)
|
|
22
|
+
|
|
23
|
+
if inspect.iscoroutinefunction(fn):
|
|
24
|
+
|
|
25
|
+
@functools.wraps(fn)
|
|
26
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
27
|
+
start = time.monotonic()
|
|
28
|
+
is_error = False
|
|
29
|
+
try:
|
|
30
|
+
return await fn(*args, **kwargs)
|
|
31
|
+
except Exception as e:
|
|
32
|
+
is_error = True
|
|
33
|
+
_logger_mod.global_logger.error(f"[Tracking] {qualname} - Error: {e!r}")
|
|
34
|
+
raise
|
|
35
|
+
finally:
|
|
36
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
37
|
+
_tracker_mod.global_tracker.track(qualname, latency_ms, is_error)
|
|
38
|
+
|
|
39
|
+
_mark_wrapped(async_wrapper, "tracking")
|
|
40
|
+
return cast(F, async_wrapper)
|
|
41
|
+
|
|
42
|
+
@functools.wraps(fn)
|
|
43
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
44
|
+
start = time.monotonic()
|
|
45
|
+
is_error = False
|
|
46
|
+
try:
|
|
47
|
+
return fn(*args, **kwargs)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
is_error = True
|
|
50
|
+
_logger_mod.global_logger.error(f"[Tracking] {qualname} - Error: {e!r}")
|
|
51
|
+
raise
|
|
52
|
+
finally:
|
|
53
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
54
|
+
_tracker_mod.global_tracker.track(qualname, latency_ms, is_error)
|
|
55
|
+
|
|
56
|
+
_mark_wrapped(sync_wrapper, "tracking")
|
|
57
|
+
return cast(F, sync_wrapper)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def tracking_class(
|
|
61
|
+
*,
|
|
62
|
+
include: tuple[str, ...] = (),
|
|
63
|
+
exclude: tuple[str, ...] = (),
|
|
64
|
+
include_private: bool = False,
|
|
65
|
+
) -> Callable[[type], type]:
|
|
66
|
+
"""Class form: auto-apply @tracking to every public method. Decision #4."""
|
|
67
|
+
|
|
68
|
+
def wrap(cls: type) -> type:
|
|
69
|
+
return apply_to_class(
|
|
70
|
+
cls,
|
|
71
|
+
tracking,
|
|
72
|
+
decorator_name="tracking",
|
|
73
|
+
include=include,
|
|
74
|
+
exclude=exclude,
|
|
75
|
+
include_private=include_private,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return wrap
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""In-process event emitter. Mirrors Node's `globalEmitter` (eventemitter3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_Listener = Callable[..., Any]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Emitter:
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._listeners: dict[str, list[_Listener]] = {}
|
|
14
|
+
|
|
15
|
+
def on(self, event: str, listener: _Listener) -> None:
|
|
16
|
+
self._listeners.setdefault(event, []).append(listener)
|
|
17
|
+
|
|
18
|
+
def off(self, event: str, listener: _Listener) -> None:
|
|
19
|
+
if event in self._listeners:
|
|
20
|
+
try:
|
|
21
|
+
self._listeners[event].remove(listener)
|
|
22
|
+
except ValueError:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def emit(self, event: str, *args: Any, **kwargs: Any) -> None:
|
|
26
|
+
for listener in list(self._listeners.get(event, [])):
|
|
27
|
+
listener(*args, **kwargs)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
global_emitter: _Emitter = _Emitter()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Pluggable logger. Default goes to the stdlib logger; override via `set_global_logger`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Logger(Protocol):
|
|
10
|
+
def debug(self, msg: object) -> None: ...
|
|
11
|
+
def info(self, msg: object) -> None: ...
|
|
12
|
+
def warn(self, msg: object) -> None: ...
|
|
13
|
+
def error(self, msg: object) -> None: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _DefaultLogger:
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._log = logging.getLogger("server_decorator")
|
|
19
|
+
|
|
20
|
+
def debug(self, msg: object) -> None:
|
|
21
|
+
self._log.debug("%s", msg)
|
|
22
|
+
|
|
23
|
+
def info(self, msg: object) -> None:
|
|
24
|
+
self._log.info("%s", msg)
|
|
25
|
+
|
|
26
|
+
def warn(self, msg: object) -> None:
|
|
27
|
+
self._log.warning("%s", msg)
|
|
28
|
+
|
|
29
|
+
def error(self, msg: object) -> None:
|
|
30
|
+
self._log.error("%s", msg)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
global_logger: Logger = _DefaultLogger()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_global_logger(logger: Logger) -> None:
|
|
37
|
+
global global_logger
|
|
38
|
+
global_logger = logger
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Queue subsystem: message format, adapter protocol, registry, built-in adapters."""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""QueueAdapter Protocol + the global single-queue helpers (mirror Node's `globalQueue`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from server_decorator.queue.message import QueueMessage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class QueueAdapter(Protocol):
|
|
12
|
+
async def publish(self, message: QueueMessage) -> None: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
global_queue: QueueAdapter | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def set_global_queue(adapter: QueueAdapter) -> None:
|
|
19
|
+
global global_queue
|
|
20
|
+
global_queue = adapter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def has_global_queue() -> bool:
|
|
24
|
+
return global_queue is not None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["QueueAdapter", "global_queue", "set_global_queue", "has_global_queue"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Awaitable is imported only to keep the type stub importable in editor tooling
|
|
31
|
+
_ = Awaitable
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in queue adapters."""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""In-memory queue adapter for unit tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from server_decorator.queue.message import QueueMessage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InMemoryQueueAdapter:
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self.messages: dict[str, list[QueueMessage]] = {}
|
|
13
|
+
|
|
14
|
+
async def publish(self, message: QueueMessage) -> None:
|
|
15
|
+
topic_raw: Any = (message.get("metadata") or {}).get("topic")
|
|
16
|
+
topic = topic_raw if isinstance(topic_raw, str) and topic_raw else "default"
|
|
17
|
+
self.messages.setdefault(topic, []).append(message)
|
|
18
|
+
|
|
19
|
+
def clear(self) -> None:
|
|
20
|
+
self.messages.clear()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Kafka adapter (aiokafka). Topic comes from the message metadata.
|
|
2
|
+
|
|
3
|
+
The producer is typed `Any` to avoid pulling aiokafka's untyped surface into
|
|
4
|
+
the strict-mypy boundary. The runtime expectation is `aiokafka.AIOKafkaProducer`
|
|
5
|
+
or any duck-typed equivalent with `send_and_wait(topic, value, key=...)`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from server_decorator.queue.message import QueueMessage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KafkaAdapter:
|
|
17
|
+
def __init__(self, producer: Any, default_topic: str = "default") -> None:
|
|
18
|
+
self._producer = producer
|
|
19
|
+
self._default_topic = default_topic
|
|
20
|
+
|
|
21
|
+
async def publish(self, message: QueueMessage) -> None:
|
|
22
|
+
body = json.dumps(message, separators=(",", ":")).encode("utf-8")
|
|
23
|
+
topic_raw = (message.get("metadata") or {}).get("topic")
|
|
24
|
+
topic = topic_raw if isinstance(topic_raw, str) and topic_raw else self._default_topic
|
|
25
|
+
key = message["eventName"].encode("utf-8")
|
|
26
|
+
await self._producer.send_and_wait(topic, body, key=key)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""No-op queue adapter — drops every message. Useful as a safe default."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from server_decorator.queue.message import QueueMessage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NoOpQueueAdapter:
|
|
9
|
+
async def publish(self, message: QueueMessage) -> None:
|
|
10
|
+
return None
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""RabbitMQ adapter (aio-pika).
|
|
2
|
+
|
|
3
|
+
The channel is typed `Any` to avoid pulling aio-pika's untyped surface into the
|
|
4
|
+
strict-mypy boundary. Runtime expectation: `aio_pika.abc.AbstractChannel`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from server_decorator.queue.message import QueueMessage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RabbitMQAdapter:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
channel: Any,
|
|
19
|
+
queue_or_exchange: str,
|
|
20
|
+
*,
|
|
21
|
+
exchange: bool = False,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._channel = channel
|
|
24
|
+
self._target = queue_or_exchange
|
|
25
|
+
self._is_exchange = exchange
|
|
26
|
+
|
|
27
|
+
async def publish(self, message: QueueMessage) -> None:
|
|
28
|
+
from aio_pika import Message
|
|
29
|
+
|
|
30
|
+
body = json.dumps(message, separators=(",", ":")).encode("utf-8")
|
|
31
|
+
amqp_message = Message(body=body, content_type="application/json")
|
|
32
|
+
if self._is_exchange:
|
|
33
|
+
exchange = await self._channel.get_exchange(self._target)
|
|
34
|
+
topic_raw = (message.get("metadata") or {}).get("topic")
|
|
35
|
+
routing_key = topic_raw if isinstance(topic_raw, str) else "default"
|
|
36
|
+
await exchange.publish(amqp_message, routing_key=routing_key)
|
|
37
|
+
else:
|
|
38
|
+
await self._channel.default_exchange.publish(amqp_message, routing_key=self._target)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Redis Streams adapter (XADD).
|
|
2
|
+
|
|
3
|
+
The client is typed `Any` to avoid pulling redis-py's untyped surface into the
|
|
4
|
+
strict-mypy boundary. Runtime expectation: `redis.asyncio.Redis` or equivalent.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from server_decorator.queue.message import QueueMessage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RedisStreamAdapter:
|
|
16
|
+
def __init__(self, client: Any, stream: str = "default") -> None:
|
|
17
|
+
self._client = client
|
|
18
|
+
self._stream = stream
|
|
19
|
+
|
|
20
|
+
async def publish(self, message: QueueMessage) -> None:
|
|
21
|
+
body = json.dumps(message, separators=(",", ":"))
|
|
22
|
+
await self._client.xadd(self._stream, {"data": body})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""QueueMessage TypedDict — the wire format. See contracts/schemas/queue-message.schema.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QueueMessageMetadata(TypedDict, total=False):
|
|
9
|
+
className: str
|
|
10
|
+
methodName: str
|
|
11
|
+
topic: str | None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _QueueMessageRequired(TypedDict):
|
|
15
|
+
eventName: str
|
|
16
|
+
payload: Any
|
|
17
|
+
timestamp: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class QueueMessage(_QueueMessageRequired, total=False):
|
|
21
|
+
metadata: QueueMessageMetadata
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""QueueRegistry — register multiple named adapters; route by name + topic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from server_decorator.queue.adapter import QueueAdapter
|
|
8
|
+
from server_decorator.queue.message import QueueMessage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class QueueConfig:
|
|
13
|
+
adapter: QueueAdapter
|
|
14
|
+
default_topic: str = "default"
|
|
15
|
+
priority: int = 0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class QueueRegistry:
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._configs: dict[str, QueueConfig] = {}
|
|
21
|
+
self._default_name: str | None = None
|
|
22
|
+
|
|
23
|
+
def register(self, name: str, config: QueueConfig | QueueAdapter) -> None:
|
|
24
|
+
if isinstance(config, QueueConfig):
|
|
25
|
+
self._configs[name] = config
|
|
26
|
+
else:
|
|
27
|
+
self._configs[name] = QueueConfig(adapter=config)
|
|
28
|
+
if self._default_name is None:
|
|
29
|
+
self._default_name = name
|
|
30
|
+
|
|
31
|
+
def set_default(self, name: str) -> None:
|
|
32
|
+
if name not in self._configs:
|
|
33
|
+
raise KeyError(f"queue {name!r} not registered")
|
|
34
|
+
self._default_name = name
|
|
35
|
+
|
|
36
|
+
def get(self, name: str | None = None) -> QueueConfig:
|
|
37
|
+
if name is not None:
|
|
38
|
+
if name not in self._configs:
|
|
39
|
+
raise KeyError(f"queue {name!r} not registered")
|
|
40
|
+
return self._configs[name]
|
|
41
|
+
if self._default_name is not None:
|
|
42
|
+
return self._configs[self._default_name]
|
|
43
|
+
if not self._configs:
|
|
44
|
+
raise RuntimeError("no queues registered")
|
|
45
|
+
return next(iter(self._configs.values()))
|
|
46
|
+
|
|
47
|
+
def clear(self) -> None:
|
|
48
|
+
self._configs.clear()
|
|
49
|
+
self._default_name = None
|
|
50
|
+
|
|
51
|
+
async def publish(
|
|
52
|
+
self,
|
|
53
|
+
message: QueueMessage,
|
|
54
|
+
queue_name: str | None = None,
|
|
55
|
+
topic: str | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
config = self.get(queue_name)
|
|
58
|
+
meta = dict(message.get("metadata") or {})
|
|
59
|
+
meta["topic"] = topic or meta.get("topic") or config.default_topic
|
|
60
|
+
message["metadata"] = meta # type: ignore[typeddict-item]
|
|
61
|
+
await config.adapter.publish(message)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
global_queue_registry: QueueRegistry = QueueRegistry()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Pluggable tracker. Default prints to logger; override via `set_global_tracker`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from server_decorator import logger as _logger_mod
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Tracker(Protocol):
|
|
11
|
+
def track(self, key: str, latency_ms: int, is_error: bool) -> None: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _DefaultTracker:
|
|
15
|
+
def track(self, key: str, latency_ms: int, is_error: bool) -> None:
|
|
16
|
+
_logger_mod.global_logger.info(
|
|
17
|
+
f"[Tracking] key={key} latency_ms={latency_ms} is_error={is_error}"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
global_tracker: Tracker = _DefaultTracker()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def set_global_tracker(tracker: Tracker) -> None:
|
|
25
|
+
global global_tracker
|
|
26
|
+
global_tracker = tracker
|
|
@@ -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,26 @@
|
|
|
1
|
+
server_decorator/__init__.py,sha256=GbYU-BVKtk3YlFiXCaUv3WmJxWt6izN4NoA7XhHGm3c,1992
|
|
2
|
+
server_decorator/_contract.py,sha256=829g2iarn9_32W0TdNVuTXThQJDd-1Bd_LApE4NVY8A,100
|
|
3
|
+
server_decorator/_version.py,sha256=_7OlQdbVkK4jad0CLdpI0grT-zEAb-qgFmH5mFzDXiA,22
|
|
4
|
+
server_decorator/emitter.py,sha256=nO82oxxqjvcCTu4sgHaLt1QhSjBksoucEkvwza69SWg,870
|
|
5
|
+
server_decorator/logger.py,sha256=1OzywwqjL_wUhvIX2oqtb-qMrECDyHEYKU7E9E-JSwA,950
|
|
6
|
+
server_decorator/tracker.py,sha256=1wwbRG7cdjl0PMLTTD4Rm3U_AbUnklebOzPUuLy881o,686
|
|
7
|
+
server_decorator/decorators/__init__.py,sha256=ZF0ClwuKfevMobKHj1HXZjyfwage58tvnAO4_SHE5bA,28
|
|
8
|
+
server_decorator/decorators/_cache_key.py,sha256=37EBkhGo0btqyCyvUUraAF-NfVwWPGF5UFPK8RKei9s,994
|
|
9
|
+
server_decorator/decorators/_class_apply.py,sha256=1TISfLzwBa2JMFYen2boqrjwGVVm7hc6nGj0cUgtkD8,2398
|
|
10
|
+
server_decorator/decorators/_qualname.py,sha256=ilaPqNb03hdOly12iMK3CpsCJdBwUzqo5_QUmnpgULQ,388
|
|
11
|
+
server_decorator/decorators/cache.py,sha256=r-Xd16YVkarZ30nBrzjcXln3Z-DVs3y_7dYd0EhaVSY,3060
|
|
12
|
+
server_decorator/decorators/emit_on_success.py,sha256=95CgcN5Er2_ZOPAy33qHvTgq4oS6yMF2pZNUb4vajDU,6201
|
|
13
|
+
server_decorator/decorators/tracking.py,sha256=jjYrgzWCy2nq7RISG06DeHq_0w1-BqKB32ryoYTjuXc,2521
|
|
14
|
+
server_decorator/queue/__init__.py,sha256=eCQg3CrGoJ5QP7hwDNvKSuKXfrbtRKrmWSl86M_gnT0,86
|
|
15
|
+
server_decorator/queue/adapter.py,sha256=NXVQzCihgPuHktuGk5oDxzWTsn4Q1_U-CwmYBSfYC7g,749
|
|
16
|
+
server_decorator/queue/message.py,sha256=S_qXEkvajYXvQZTtllK7At3TZ0V_30jmga1HR5-bYFE,475
|
|
17
|
+
server_decorator/queue/registry.py,sha256=dTGXnxw1DTmLuGZagMtxKj_4giz3tUpm2Cj76YBwQoE,2081
|
|
18
|
+
server_decorator/queue/adapters/__init__.py,sha256=cIk1fRtd9MqIBZ7XFtgEappfdzwUHbV9TTlNWFAJ4G0,31
|
|
19
|
+
server_decorator/queue/adapters/inmemory.py,sha256=H6IcEfIfftyBK9dJbgqzEeVG0ZFn7csNXNvYd00U8B4,619
|
|
20
|
+
server_decorator/queue/adapters/kafka_adapter.py,sha256=cMsdlyaz9N-H_g-yRRcsoozhMm7K05ZA0BAIqdlLvCM,1024
|
|
21
|
+
server_decorator/queue/adapters/noop.py,sha256=fhK0BNnvoB401V0p5WMPlOfXdbQSVIY5M5WJkPzkgq0,276
|
|
22
|
+
server_decorator/queue/adapters/rabbitmq_adapter.py,sha256=X71PlZ2qIXC2wt6aVAbgp2PmZiQzqeXkzOOCd1eCj7I,1283
|
|
23
|
+
server_decorator/queue/adapters/redis_adapter.py,sha256=ZEDgmBsT9Gz_d82pVxAefvt7lh7WL24iYP_Oo8akdhE,665
|
|
24
|
+
server_decorator-2.0.0.dist-info/METADATA,sha256=Xvjtr-yo3IVH01QR2-P06_oGWs5zZKYgC2CytkQT2PI,4351
|
|
25
|
+
server_decorator-2.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
26
|
+
server_decorator-2.0.0.dist-info/RECORD,,
|