python-wrap 0.2.1__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.
python_wrap/policy.py ADDED
@@ -0,0 +1,263 @@
1
+ """Reusable, named bundles of decorators that always stack in the right order."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from typing import Any, NoReturn, TypeVar, cast
7
+
8
+ F = TypeVar("F", bound=Callable[..., Any])
9
+
10
+ __all__ = ["ROLES", "Policy", "policy"]
11
+
12
+ _Decorator = Callable[[Any], Any]
13
+
14
+ #: Role names, outermost first. The canonical order, with the reason for each position:
15
+ #:
16
+ #: - ``observe``: timing, logging, audit see the outcome the caller experiences.
17
+ #: - ``validate``: reject bad input before anything is retried, cached or counted.
18
+ #: - ``fallback``: catches whatever the layers below could not fix.
19
+ #: - ``cache``: a hit skips everything below; failures are never cached.
20
+ #: - ``limiter``: one rate-limit token per logical call, not per attempt.
21
+ #: - ``bulkhead``: caps concurrent logical calls into the dependency.
22
+ #: - ``breaker``: counts calls whose retries were exhausted.
23
+ #: - ``retry``: repeats everything below it.
24
+ #: - ``timeout``: a deadline per attempt.
25
+ #: - ``guard``: per-attempt state protection (revert_on_failure, transactional).
26
+ ROLES: tuple[str, ...] = (
27
+ "observe",
28
+ "validate",
29
+ "fallback",
30
+ "cache",
31
+ "limiter",
32
+ "bulkhead",
33
+ "breaker",
34
+ "retry",
35
+ "timeout",
36
+ "guard",
37
+ )
38
+
39
+
40
+ def _as_tuple(role: str, value: Any) -> tuple[_Decorator, ...]:
41
+ if value is None:
42
+ return ()
43
+ items = tuple(value) if isinstance(value, (list, tuple)) else (value,)
44
+ for item in items:
45
+ if not callable(item):
46
+ raise TypeError(f"policy {role}= must be a decorator or a list of them; got {item!r}")
47
+ return items
48
+
49
+
50
+ def _apply_one(decorator: _Decorator, target: Any) -> Any:
51
+ """Apply a decorator, looking through staticmethod/classmethod descriptors."""
52
+ if isinstance(target, (staticmethod, classmethod)):
53
+ return type(target)(decorator(target.__func__))
54
+ return decorator(target)
55
+
56
+
57
+ class Policy:
58
+ """An immutable, named bundle of decorators applied in a fixed, correct order.
59
+
60
+ Create one with `policy`. A `Policy` is itself a decorator, so it works on
61
+ plain functions, ``async def`` functions and methods, and keeps the
62
+ decorated function's type. Roles run outermost first in the order of `ROLES`,
63
+ wherever you pass them; `outer` decorators wrap all roles and `inner`
64
+ decorators sit inside all roles, each group in the order given.
65
+
66
+ Example:
67
+ ```python
68
+ resilient = policy(retry=retry(max_attempts=3), timeout=timeout(2), name="io")
69
+
70
+ @resilient
71
+ def fetch() -> str: ...
72
+ ```
73
+ """
74
+
75
+ __slots__ = ("_inner", "_name", "_outer", "_roles")
76
+
77
+ _name: str | None
78
+ _outer: tuple[_Decorator, ...]
79
+ _inner: tuple[_Decorator, ...]
80
+ _roles: dict[str, tuple[_Decorator, ...]]
81
+
82
+ def __reduce__(self) -> NoReturn:
83
+ raise TypeError(
84
+ "cannot pickle a Policy: it holds decorator closures. Define the policy at "
85
+ "module level so each process builds it on import; functions decorated "
86
+ "with it pickle by reference."
87
+ )
88
+
89
+ def __init__(
90
+ self,
91
+ *,
92
+ name: str | None,
93
+ outer: tuple[_Decorator, ...],
94
+ inner: tuple[_Decorator, ...],
95
+ roles: Mapping[str, tuple[_Decorator, ...]],
96
+ ) -> None:
97
+ object.__setattr__(self, "_name", name)
98
+ object.__setattr__(self, "_outer", outer)
99
+ object.__setattr__(self, "_inner", inner)
100
+ object.__setattr__(self, "_roles", dict(roles))
101
+
102
+ def __setattr__(self, key: str, value: Any) -> None:
103
+ raise AttributeError("Policy is immutable; use .with_(...) to derive a variant")
104
+
105
+ def __delattr__(self, key: str) -> None:
106
+ raise AttributeError("Policy is immutable; use .with_(...) to derive a variant")
107
+
108
+ @property
109
+ def name(self) -> str | None:
110
+ """The policy name, if any."""
111
+ return self._name
112
+
113
+ @property
114
+ def order(self) -> tuple[str, ...]:
115
+ """The final layer names, outermost first (``outer``, roles, ``inner``)."""
116
+ names: list[str] = ["outer"] * len(self._outer)
117
+ for role in ROLES:
118
+ names += [role] * len(self._roles.get(role, ()))
119
+ names += ["inner"] * len(self._inner)
120
+ return tuple(names)
121
+
122
+ def _layers(self) -> list[_Decorator]:
123
+ layers: list[_Decorator] = list(self._outer)
124
+ for role in ROLES:
125
+ layers.extend(self._roles.get(role, ()))
126
+ layers.extend(self._inner)
127
+ return layers
128
+
129
+ def apply(self, func: F) -> F:
130
+ """Decorate `func` with every layer in the policy's order.
131
+
132
+ Args:
133
+ func: A function, ``async def`` function, or static/class method.
134
+
135
+ Returns:
136
+ The decorated function (same type as `func`).
137
+ """
138
+ result: Any = func
139
+ for decorator in reversed(self._layers()):
140
+ result = _apply_one(decorator, result)
141
+ return cast(F, result)
142
+
143
+ def __call__(self, func: F) -> F:
144
+ return self.apply(func)
145
+
146
+ def with_(self, **changes: Any) -> Policy:
147
+ """Return a new policy with some roles replaced.
148
+
149
+ Args:
150
+ **changes: Any role name, `outer`, `inner` or `name`. A value of
151
+ ``None`` removes that role; otherwise it replaces it.
152
+
153
+ Returns:
154
+ A new `Policy`; this one is unchanged.
155
+
156
+ Raises:
157
+ TypeError: For an unknown keyword or a non-callable decorator.
158
+ """
159
+ known = {*ROLES, "outer", "inner", "name"}
160
+ unknown = set(changes) - known
161
+ if unknown:
162
+ raise TypeError(f"unknown policy option(s): {', '.join(sorted(unknown))}")
163
+ roles = dict(self._roles)
164
+ for role in ROLES:
165
+ if role in changes:
166
+ roles[role] = _as_tuple(role, changes[role])
167
+ return Policy(
168
+ name=changes.get("name", self._name),
169
+ outer=_as_tuple("outer", changes["outer"]) if "outer" in changes else self._outer,
170
+ inner=_as_tuple("inner", changes["inner"]) if "inner" in changes else self._inner,
171
+ roles=roles,
172
+ )
173
+
174
+ def __repr__(self) -> str:
175
+ order = " > ".join(self.order) or "(empty)"
176
+ label = f"{self._name!r}, " if self._name else ""
177
+ return f"Policy({label}{order})"
178
+
179
+
180
+ def policy(
181
+ *,
182
+ name: str | None = None,
183
+ observe: _Decorator | Sequence[_Decorator] | None = None,
184
+ validate: _Decorator | Sequence[_Decorator] | None = None,
185
+ fallback: _Decorator | Sequence[_Decorator] | None = None,
186
+ cache: _Decorator | Sequence[_Decorator] | None = None,
187
+ limiter: _Decorator | Sequence[_Decorator] | None = None,
188
+ bulkhead: _Decorator | Sequence[_Decorator] | None = None,
189
+ breaker: _Decorator | Sequence[_Decorator] | None = None,
190
+ retry: _Decorator | Sequence[_Decorator] | None = None,
191
+ timeout: _Decorator | Sequence[_Decorator] | None = None,
192
+ guard: _Decorator | Sequence[_Decorator] | None = None,
193
+ outer: _Decorator | Sequence[_Decorator] | None = None,
194
+ inner: _Decorator | Sequence[_Decorator] | None = None,
195
+ ) -> Policy:
196
+ """Build a reusable `Policy` from decorators placed in named roles.
197
+
198
+ Each keyword is a role, and the role (not the keyword order) decides where the
199
+ decorator sits. The canonical order, outermost first, is `ROLES`: ``observe``
200
+ (timing, logging), ``validate``, ``fallback``, ``cache``, ``limiter``
201
+ (rate limits), ``bulkhead``, ``breaker`` (a `CircuitBreaker` or
202
+ `circuit_breaker(...)`), ``retry``, ``timeout``, ``guard``
203
+ (revert_on_failure and similar). The reasons are in the stacking guide.
204
+
205
+ A role takes one decorator or a list of them (outermost first). Use `outer`
206
+ and `inner` for any decorator that has no role: `outer` ones wrap every role,
207
+ `inner` ones sit just above the function.
208
+
209
+ Args:
210
+ name: Label shown in ``repr``.
211
+ observe: Observers such as `timing` or `audit`.
212
+ validate: Input checks such as `validate_args`.
213
+ fallback: `fallback` or `suppress`.
214
+ cache: `memoize`, `once`, `singleflight`.
215
+ limiter: `rate_limit`, `throttle`.
216
+ bulkhead: a `Bulkhead` or `limit_concurrency(n)`.
217
+ breaker: A `CircuitBreaker` or `circuit_breaker(...)`.
218
+ retry: `retry(...)`.
219
+ timeout: `timeout(...)`.
220
+ guard: Per-attempt state protection.
221
+ outer: Unclassified decorators placed outside every role.
222
+ inner: Unclassified decorators placed inside every role.
223
+
224
+ Returns:
225
+ An immutable `Policy`, usable as a decorator.
226
+
227
+ Raises:
228
+ TypeError: If an argument is not callable (or a list of callables).
229
+
230
+ Example:
231
+ ```python
232
+ from python_wrap import CircuitBreaker, retry, timeout, policy
233
+
234
+ resilient = policy(
235
+ timeout=timeout(2),
236
+ retry=retry(max_attempts=3, on=ConnectionError),
237
+ breaker=CircuitBreaker(failure_threshold=5, on=ConnectionError),
238
+ name="payments",
239
+ )
240
+
241
+ @resilient
242
+ def charge(amount: int) -> int:
243
+ return amount
244
+ ```
245
+ """
246
+ given = {
247
+ "observe": observe,
248
+ "validate": validate,
249
+ "fallback": fallback,
250
+ "cache": cache,
251
+ "limiter": limiter,
252
+ "bulkhead": bulkhead,
253
+ "breaker": breaker,
254
+ "retry": retry,
255
+ "timeout": timeout,
256
+ "guard": guard,
257
+ }
258
+ return Policy(
259
+ name=name,
260
+ outer=_as_tuple("outer", outer),
261
+ inner=_as_tuple("inner", inner),
262
+ roles={role: _as_tuple(role, value) for role, value in given.items()},
263
+ )
python_wrap/py.typed ADDED
File without changes