haiway 0.1.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,329 @@
1
+ from asyncio import Future, gather, get_event_loop
2
+ from collections.abc import Callable
3
+ from contextvars import ContextVar, Token
4
+ from copy import copy
5
+ from itertools import chain
6
+ from logging import DEBUG, ERROR, INFO, WARNING, Logger, getLogger
7
+ from time import monotonic
8
+ from types import TracebackType
9
+ from typing import Any, Self, cast, final, overload
10
+ from uuid import uuid4
11
+
12
+ from haiway.state import Structure
13
+ from haiway.utils import freeze
14
+
15
+ __all__ = [
16
+ "ScopeMetrics",
17
+ "MetricsContext",
18
+ ]
19
+
20
+
21
+ @final
22
+ class ScopeMetrics:
23
+ def __init__(
24
+ self,
25
+ *,
26
+ trace_id: str | None,
27
+ scope: str,
28
+ logger: Logger | None,
29
+ ) -> None:
30
+ self.trace_id: str = trace_id or uuid4().hex
31
+ self._label: str = f"{self.trace_id}|{scope}" if scope else self.trace_id
32
+ self._logger: Logger = logger or getLogger(name=scope)
33
+ self._metrics: dict[type[Structure], Structure] = {}
34
+ self._nested: list[ScopeMetrics] = []
35
+ self._timestamp: float = monotonic()
36
+ self._completed: Future[float] = get_event_loop().create_future()
37
+
38
+ freeze(self)
39
+
40
+ def __del__(self) -> None:
41
+ self._complete() # ensure completion on deinit
42
+
43
+ def __str__(self) -> str:
44
+ return self._label
45
+
46
+ def metrics(
47
+ self,
48
+ *,
49
+ merge: Callable[[Structure, Structure], Structure] = lambda lhs, rhs: lhs,
50
+ ) -> list[Structure]:
51
+ metrics: dict[type[Structure], Structure] = copy(self._metrics)
52
+ for metric in chain.from_iterable(nested.metrics(merge=merge) for nested in self._nested):
53
+ metric_type: type[Structure] = type(metric)
54
+ if current := metrics.get(metric_type):
55
+ metrics[metric_type] = merge(current, metric)
56
+
57
+ else:
58
+ metrics[metric_type] = metric
59
+
60
+ return list(metrics.values())
61
+
62
+ @overload
63
+ def read[Metric: Structure](
64
+ self,
65
+ metric: type[Metric],
66
+ /,
67
+ ) -> Metric | None: ...
68
+
69
+ @overload
70
+ def read[Metric: Structure](
71
+ self,
72
+ metric: type[Metric],
73
+ /,
74
+ default: Metric,
75
+ ) -> Metric: ...
76
+
77
+ def read[Metric: Structure](
78
+ self,
79
+ metric: type[Metric],
80
+ /,
81
+ default: Metric | None = None,
82
+ ) -> Metric | None:
83
+ return cast(Metric | None, self._metrics.get(metric, default))
84
+
85
+ def record[Metric: Structure](
86
+ self,
87
+ metric: Metric,
88
+ /,
89
+ *,
90
+ merge: Callable[[Metric, Metric], Metric] = lambda lhs, rhs: rhs,
91
+ ) -> None:
92
+ assert not self._completed.done(), "Can't record using completed metrics scope" # nosec: B101
93
+ metric_type: type[Metric] = type(metric)
94
+ if current := self._metrics.get(metric_type):
95
+ self._metrics[metric_type] = merge(cast(Metric, current), metric)
96
+
97
+ else:
98
+ self._metrics[metric_type] = metric
99
+
100
+ @property
101
+ def completed(self) -> bool:
102
+ return self._completed.done() and all(nested.completed for nested in self._nested)
103
+
104
+ @property
105
+ def time(self) -> float:
106
+ if self._completed.done():
107
+ return self._completed.result()
108
+
109
+ else:
110
+ return monotonic() - self._timestamp
111
+
112
+ async def wait(self) -> None:
113
+ await gather(
114
+ self._completed,
115
+ *[nested.wait() for nested in self._nested],
116
+ return_exceptions=False,
117
+ )
118
+
119
+ def _complete(self) -> None:
120
+ if self._completed.done():
121
+ return # already completed
122
+
123
+ self._completed.set_result(monotonic() - self._timestamp)
124
+
125
+ def scope(
126
+ self,
127
+ name: str,
128
+ /,
129
+ ) -> Self:
130
+ nested: Self = self.__class__(
131
+ scope=name,
132
+ logger=self._logger,
133
+ trace_id=self.trace_id,
134
+ )
135
+ self._nested.append(nested)
136
+ return nested
137
+
138
+ def log(
139
+ self,
140
+ level: int,
141
+ message: str,
142
+ /,
143
+ *args: Any,
144
+ exception: BaseException | None = None,
145
+ ) -> None:
146
+ self._logger.log(
147
+ level,
148
+ f"[{self}] {message}",
149
+ *args,
150
+ exc_info=exception,
151
+ )
152
+
153
+
154
+ @final
155
+ class MetricsContext:
156
+ _context = ContextVar[ScopeMetrics]("MetricsContext")
157
+
158
+ @classmethod
159
+ def scope(
160
+ cls,
161
+ name: str,
162
+ /,
163
+ *,
164
+ trace_id: str | None = None,
165
+ logger: Logger | None = None,
166
+ ) -> Self:
167
+ try:
168
+ context: ScopeMetrics = cls._context.get()
169
+ if trace_id is None or context.trace_id == trace_id:
170
+ return cls(context.scope(name))
171
+
172
+ else:
173
+ return cls(
174
+ ScopeMetrics(
175
+ trace_id=trace_id,
176
+ scope=name,
177
+ logger=logger or context._logger, # pyright: ignore[reportPrivateUsage]
178
+ )
179
+ )
180
+ except LookupError: # create metrics scope when missing yet
181
+ return cls(
182
+ ScopeMetrics(
183
+ trace_id=trace_id,
184
+ scope=name,
185
+ logger=logger,
186
+ )
187
+ )
188
+
189
+ @classmethod
190
+ def record[Metric: Structure](
191
+ cls,
192
+ metric: Metric,
193
+ /,
194
+ *,
195
+ merge: Callable[[Metric, Metric], Metric] = lambda lhs, rhs: rhs,
196
+ ) -> None:
197
+ try: # catch exceptions - we don't wan't to blow up on metrics
198
+ cls._context.get().record(metric, merge=merge)
199
+
200
+ except Exception as exc:
201
+ cls.log_error(
202
+ "Failed to record metric: %s",
203
+ type(metric).__qualname__,
204
+ exception=exc,
205
+ )
206
+
207
+ # - LOGS -
208
+
209
+ @classmethod
210
+ def log_error(
211
+ cls,
212
+ message: str,
213
+ /,
214
+ *args: Any,
215
+ exception: BaseException | None = None,
216
+ ) -> None:
217
+ try:
218
+ cls._context.get().log(
219
+ ERROR,
220
+ message,
221
+ *args,
222
+ exception=exception,
223
+ )
224
+
225
+ except LookupError:
226
+ getLogger().log(
227
+ ERROR,
228
+ message,
229
+ *args,
230
+ exc_info=exception,
231
+ )
232
+
233
+ @classmethod
234
+ def log_warning(
235
+ cls,
236
+ message: str,
237
+ /,
238
+ *args: Any,
239
+ exception: Exception | None = None,
240
+ ) -> None:
241
+ try:
242
+ cls._context.get().log(
243
+ WARNING,
244
+ message,
245
+ *args,
246
+ exception=exception,
247
+ )
248
+
249
+ except LookupError:
250
+ getLogger().log(
251
+ WARNING,
252
+ message,
253
+ *args,
254
+ exc_info=exception,
255
+ )
256
+
257
+ @classmethod
258
+ def log_info(
259
+ cls,
260
+ message: str,
261
+ /,
262
+ *args: Any,
263
+ ) -> None:
264
+ try:
265
+ cls._context.get().log(
266
+ INFO,
267
+ message,
268
+ *args,
269
+ )
270
+
271
+ except LookupError:
272
+ getLogger().log(
273
+ INFO,
274
+ message,
275
+ *args,
276
+ )
277
+
278
+ @classmethod
279
+ def log_debug(
280
+ cls,
281
+ message: str,
282
+ /,
283
+ *args: Any,
284
+ exception: Exception | None = None,
285
+ ) -> None:
286
+ try:
287
+ cls._context.get().log(
288
+ DEBUG,
289
+ message,
290
+ *args,
291
+ exception=exception,
292
+ )
293
+
294
+ except LookupError:
295
+ getLogger().log(
296
+ DEBUG,
297
+ message,
298
+ *args,
299
+ exc_info=exception,
300
+ )
301
+
302
+ def __init__(
303
+ self,
304
+ metrics: ScopeMetrics,
305
+ ) -> None:
306
+ self._metrics: ScopeMetrics = metrics
307
+ self._token: Token[ScopeMetrics] | None = None
308
+ self._started: float | None = None
309
+ self._finished: float | None = None
310
+
311
+ def __enter__(self) -> None:
312
+ assert ( # nosec: B101
313
+ self._token is None and self._started is None
314
+ ), "MetricsContext reentrance is not allowed"
315
+ self._token = MetricsContext._context.set(self._metrics)
316
+ self._started = monotonic()
317
+
318
+ def __exit__(
319
+ self,
320
+ exc_type: type[BaseException] | None,
321
+ exc_val: BaseException | None,
322
+ exc_tb: TracebackType | None,
323
+ ) -> None:
324
+ assert ( # nosec: B101
325
+ self._token is not None and self._started is not None and self._finished is None
326
+ ), "Unbalanced MetricsContext context enter/exit"
327
+ self._finished = monotonic()
328
+ MetricsContext._context.reset(self._token)
329
+ self._token = None
@@ -0,0 +1,115 @@
1
+ from collections.abc import Iterable
2
+ from contextvars import ContextVar, Token
3
+ from types import TracebackType
4
+ from typing import Self, cast, final
5
+
6
+ from haiway.context.types import MissingContext, MissingState
7
+ from haiway.state import Structure
8
+ from haiway.utils import freeze
9
+
10
+ __all__ = [
11
+ "ScopeState",
12
+ "StateContext",
13
+ ]
14
+
15
+
16
+ @final
17
+ class ScopeState:
18
+ def __init__(
19
+ self,
20
+ state: Iterable[Structure],
21
+ ) -> None:
22
+ self._state: dict[type[Structure], Structure] = {
23
+ type(element): element for element in state
24
+ }
25
+ freeze(self)
26
+
27
+ def state[State: Structure](
28
+ self,
29
+ state: type[State],
30
+ /,
31
+ default: State | None = None,
32
+ ) -> State:
33
+ if state in self._state:
34
+ return cast(State, self._state[state])
35
+
36
+ elif default is not None:
37
+ return default
38
+
39
+ else:
40
+ try:
41
+ initialized: State = state()
42
+ self._state[state] = initialized
43
+ return initialized
44
+
45
+ except Exception as exc:
46
+ raise MissingState(
47
+ f"{state.__qualname__} is not defined in current scope"
48
+ " and failed to provide a default value"
49
+ ) from exc
50
+
51
+ def updated(
52
+ self,
53
+ state: Iterable[Structure],
54
+ ) -> Self:
55
+ if state:
56
+ return self.__class__(
57
+ [
58
+ *self._state.values(),
59
+ *state,
60
+ ]
61
+ )
62
+
63
+ else:
64
+ return self
65
+
66
+
67
+ @final
68
+ class StateContext:
69
+ _context = ContextVar[ScopeState]("StateContext")
70
+
71
+ @classmethod
72
+ def current[State: Structure](
73
+ cls,
74
+ state: type[State],
75
+ /,
76
+ default: State | None = None,
77
+ ) -> State:
78
+ try:
79
+ return cls._context.get().state(state, default=default)
80
+
81
+ except LookupError as exc:
82
+ raise MissingContext("StateContext requested but not defined!") from exc
83
+
84
+ @classmethod
85
+ def updated(
86
+ cls,
87
+ state: Iterable[Structure],
88
+ /,
89
+ ) -> Self:
90
+ try:
91
+ return cls(state=cls._context.get().updated(state=state))
92
+
93
+ except LookupError: # create new context as a fallback
94
+ return cls(state=ScopeState(state))
95
+
96
+ def __init__(
97
+ self,
98
+ state: ScopeState,
99
+ ) -> None:
100
+ self._state: ScopeState = state
101
+ self._token: Token[ScopeState] | None = None
102
+
103
+ def __enter__(self) -> None:
104
+ assert self._token is None, "StateContext reentrance is not allowed" # nosec: B101
105
+ self._token = StateContext._context.set(self._state)
106
+
107
+ def __exit__(
108
+ self,
109
+ exc_type: type[BaseException] | None,
110
+ exc_val: BaseException | None,
111
+ exc_tb: TracebackType | None,
112
+ ) -> None:
113
+ assert self._token is not None, "Unbalanced StateContext context exit" # nosec: B101
114
+ StateContext._context.reset(self._token)
115
+ self._token = None
@@ -0,0 +1,65 @@
1
+ from asyncio import Task, TaskGroup, get_event_loop
2
+ from collections.abc import Callable, Coroutine
3
+ from contextvars import ContextVar, Token, copy_context
4
+ from types import TracebackType
5
+ from typing import final
6
+
7
+ __all__ = [
8
+ "TaskGroupContext",
9
+ ]
10
+
11
+
12
+ @final
13
+ class TaskGroupContext:
14
+ _context = ContextVar[TaskGroup]("TaskGroupContext")
15
+
16
+ @classmethod
17
+ def run[Result, **Arguments](
18
+ cls,
19
+ function: Callable[Arguments, Coroutine[None, None, Result]],
20
+ /,
21
+ *args: Arguments.args,
22
+ **kwargs: Arguments.kwargs,
23
+ ) -> Task[Result]:
24
+ try:
25
+ return cls._context.get().create_task(
26
+ function(*args, **kwargs),
27
+ context=copy_context(),
28
+ )
29
+
30
+ except LookupError: # spawn task out of group as a fallback
31
+ return get_event_loop().create_task(
32
+ function(*args, **kwargs),
33
+ context=copy_context(),
34
+ )
35
+
36
+ def __init__(
37
+ self,
38
+ ) -> None:
39
+ self._group: TaskGroup = TaskGroup()
40
+ self._token: Token[TaskGroup] | None = None
41
+
42
+ async def __aenter__(self) -> None:
43
+ assert self._token is None, "TaskGroupContext reentrance is not allowed" # nosec: B101
44
+ await self._group.__aenter__()
45
+ self._token = TaskGroupContext._context.set(self._group)
46
+
47
+ async def __aexit__(
48
+ self,
49
+ exc_type: type[BaseException] | None,
50
+ exc_val: BaseException | None,
51
+ exc_tb: TracebackType | None,
52
+ ) -> None:
53
+ assert self._token is not None, "Unbalanced TaskGroupContext context exit" # nosec: B101
54
+ TaskGroupContext._context.reset(self._token)
55
+ self._token = None
56
+
57
+ try:
58
+ await self._group.__aexit__(
59
+ et=exc_type,
60
+ exc=exc_val,
61
+ tb=exc_tb,
62
+ )
63
+
64
+ except BaseException:
65
+ pass # silence TaskGroup exceptions, if there was exception already we will get it
@@ -0,0 +1,17 @@
1
+ __all__ = [
2
+ "MissingContext",
3
+ "MissingDependency",
4
+ "MissingState",
5
+ ]
6
+
7
+
8
+ class MissingContext(Exception):
9
+ pass
10
+
11
+
12
+ class MissingDependency(Exception):
13
+ pass
14
+
15
+
16
+ class MissingState(Exception):
17
+ pass
@@ -0,0 +1,13 @@
1
+ from haiway.helpers.asynchronous import asynchronous
2
+ from haiway.helpers.cache import cached
3
+ from haiway.helpers.retry import auto_retry
4
+ from haiway.helpers.throttling import throttle
5
+ from haiway.helpers.timeout import with_timeout
6
+
7
+ __all__ = [
8
+ "asynchronous",
9
+ "auto_retry",
10
+ "cached",
11
+ "throttle",
12
+ "with_timeout",
13
+ ]