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.
@@ -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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any