haiway 0.7.1__py3-none-any.whl → 0.8.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.
haiway/context/metrics.py CHANGED
@@ -1,384 +1,118 @@
1
- from asyncio import (
2
- AbstractEventLoop,
3
- Future,
4
- gather,
5
- get_event_loop,
6
- iscoroutinefunction,
7
- run_coroutine_threadsafe,
8
- )
9
- from collections.abc import Callable, Coroutine
10
1
  from contextvars import ContextVar, Token
11
- from copy import copy
12
- from itertools import chain
13
- from logging import DEBUG, ERROR, INFO, WARNING, Logger, getLogger
14
- from time import monotonic
15
2
  from types import TracebackType
16
- from typing import Any, Self, cast, final, overload
17
- from uuid import uuid4
3
+ from typing import Protocol, Self, final, runtime_checkable
18
4
 
5
+ from haiway.context.identifier import ScopeIdentifier
6
+ from haiway.context.logging import LoggerContext
19
7
  from haiway.state import State
20
- from haiway.types import MISSING, Missing, not_missing
21
- from haiway.utils import freeze
22
8
 
23
9
  __all__ = [
24
10
  "MetricsContext",
25
- "ScopeMetrics",
11
+ "MetricsHandler",
12
+ "MetricsRecording",
13
+ "MetricsScopeEntering",
14
+ "MetricsScopeExiting",
26
15
  ]
27
16
 
28
17
 
29
- @final
30
- class ScopeMetrics:
31
- def __init__(
32
- self,
33
- *,
34
- trace_id: str | None,
35
- scope: str,
36
- logger: Logger | None,
37
- parent: Self | None,
38
- completion: Callable[[Self], Coroutine[None, None, None]] | Callable[[Self], None] | None,
39
- ) -> None:
40
- self.trace_id: str = trace_id or uuid4().hex
41
- self.identifier: str = uuid4().hex
42
- self.label: str = scope
43
- self._logger_prefix: str = (
44
- f"[{self.trace_id}] [{scope}] [{self.identifier}]"
45
- if scope
46
- else f"[{self.trace_id}] [{self.identifier}]"
47
- )
48
- self._logger: Logger = logger or getLogger(name=scope)
49
- self._parent: Self | None = parent if parent else None
50
- self._metrics: dict[type[State], State] = {}
51
- self._nested: list[ScopeMetrics] = []
52
- self._timestamp: float = monotonic()
53
- self._finished: bool = False
54
- self._loop: AbstractEventLoop = get_event_loop()
55
- self._completed: Future[float] = self._loop.create_future()
56
-
57
- if parent := parent:
58
- parent._nested.append(self)
59
-
60
- freeze(self)
61
-
62
- if completion := completion:
63
- metrics: Self = self
64
- if iscoroutinefunction(completion):
65
-
66
- def callback(_: Future[float]) -> None:
67
- run_coroutine_threadsafe(
68
- completion(metrics),
69
- metrics._loop,
70
- )
71
-
72
- else:
73
-
74
- def callback(_: Future[float]) -> None:
75
- completion(metrics)
76
-
77
- self._completed.add_done_callback(callback)
78
-
79
- def __del__(self) -> None:
80
- assert self.is_completed, "Deinitializing not completed scope metrics" # nosec: B101
81
-
82
- def __str__(self) -> str:
83
- return f"{self.label}[{self.identifier}]@[{self.trace_id}]"
84
-
85
- def metrics(
86
- self,
87
- *,
88
- merge: Callable[[State | Missing, State], State | Missing] | None = None,
89
- ) -> list[State]:
90
- if not merge:
91
- return list(self._metrics.values())
92
-
93
- metrics: dict[type[State], State] = copy(self._metrics)
94
- for metric in chain.from_iterable(nested.metrics(merge=merge) for nested in self._nested):
95
- metric_type: type[State] = type(metric)
96
- merged: State | Missing = merge(
97
- metrics.get( # current
98
- metric_type,
99
- MISSING,
100
- ),
101
- metric, # received
102
- )
103
-
104
- if not_missing(merged):
105
- metrics[metric_type] = merged
106
-
107
- return list(metrics.values())
108
-
109
- @overload
110
- def read[Metric: State](
18
+ @runtime_checkable
19
+ class MetricsRecording(Protocol):
20
+ def __call__(
111
21
  self,
112
- metric: type[Metric],
22
+ scope: ScopeIdentifier,
113
23
  /,
114
- ) -> Metric | None: ...
24
+ metric: State,
25
+ ) -> None: ...
115
26
 
116
- @overload
117
- def read[Metric: State](
118
- self,
119
- metric: type[Metric],
120
- /,
121
- default: Metric,
122
- ) -> Metric: ...
123
27
 
124
- def read[Metric: State](
28
+ @runtime_checkable
29
+ class MetricsScopeEntering(Protocol):
30
+ def __call__[Metric: State](
125
31
  self,
126
- metric: type[Metric],
32
+ scope: ScopeIdentifier,
127
33
  /,
128
- default: Metric | None = None,
129
- ) -> Metric | None:
130
- return cast(Metric | None, self._metrics.get(metric, default))
34
+ ) -> None: ...
131
35
 
132
- def record[Metric: State](
36
+
37
+ @runtime_checkable
38
+ class MetricsScopeExiting(Protocol):
39
+ def __call__[Metric: State](
133
40
  self,
134
- metric: Metric,
41
+ scope: ScopeIdentifier,
135
42
  /,
136
- *,
137
- merge: Callable[[Metric, Metric], Metric] = lambda lhs, rhs: rhs,
138
- ) -> None:
139
- assert not self._completed.done(), "Can't record using completed metrics scope" # nosec: B101
140
- metric_type: type[Metric] = type(metric)
141
- if current := self._metrics.get(metric_type):
142
- self._metrics[metric_type] = merge(cast(Metric, current), metric)
143
-
144
- else:
145
- self._metrics[metric_type] = metric
146
-
147
- @property
148
- def is_completed(self) -> bool:
149
- return self._completed.done() and all(nested.is_completed for nested in self._nested)
150
-
151
- @property
152
- def time(self) -> float:
153
- if self._completed.done():
154
- return self._completed.result()
155
-
156
- else:
157
- return monotonic() - self._timestamp
158
-
159
- async def wait(self) -> None:
160
- await gather(
161
- self._completed,
162
- *[nested.wait() for nested in self._nested],
163
- return_exceptions=False,
164
- )
165
-
166
- def _finish(self) -> None:
167
- assert ( # nosec: B101
168
- not self._completed.done()
169
- ), "Invalid state - called finish on already completed scope"
170
-
171
- assert ( # nosec: B101
172
- not self._finished
173
- ), "Invalid state - called completion on already finished scope"
174
-
175
- self._finished = True # self is now finished
176
-
177
- self._complete_if_able()
178
-
179
- def _complete_if_able(self) -> None:
180
- assert ( # nosec: B101
181
- not self._completed.done()
182
- ), "Invalid state - called complete on already completed scope"
43
+ ) -> None: ...
183
44
 
184
- if not self._finished:
185
- return # wait for finishing self
186
45
 
187
- if any(not nested.is_completed for nested in self._nested):
188
- return # wait for completing all nested scopes
189
-
190
- # set completion time
191
- self._completed.set_result(monotonic() - self._timestamp)
192
-
193
- # notify parent about completion
194
- if parent := self._parent:
195
- parent._complete_if_able()
196
-
197
- def log(
198
- self,
199
- level: int,
200
- message: str,
201
- /,
202
- *args: Any,
203
- exception: BaseException | None = None,
204
- ) -> None:
205
- self._logger.log(
206
- level,
207
- f"{self._logger_prefix} {message}",
208
- *args,
209
- exc_info=exception,
210
- )
46
+ class MetricsHandler(State):
47
+ record: MetricsRecording
48
+ enter_scope: MetricsScopeEntering
49
+ exit_scope: MetricsScopeExiting
211
50
 
212
51
 
213
52
  @final
214
53
  class MetricsContext:
215
- _context = ContextVar[ScopeMetrics]("MetricsContext")
54
+ _context = ContextVar[Self]("MetricsContext")
216
55
 
217
56
  @classmethod
218
57
  def scope(
219
58
  cls,
220
- name: str,
59
+ scope: ScopeIdentifier,
221
60
  /,
222
61
  *,
223
- trace_id: str | None = None,
224
- logger: Logger | None = None,
225
- completion: Callable[[ScopeMetrics], Coroutine[None, None, None]]
226
- | Callable[[ScopeMetrics], None]
227
- | None,
62
+ metrics: MetricsHandler | None,
228
63
  ) -> Self:
229
- current: ScopeMetrics
230
- try: # check for current scope context
64
+ current: Self
65
+ try: # check for current scope
231
66
  current = cls._context.get()
232
67
 
233
68
  except LookupError:
234
- # create metrics scope when missing yet
69
+ # create root scope when missing
235
70
  return cls(
236
- ScopeMetrics(
237
- trace_id=trace_id,
238
- scope=name,
239
- logger=logger,
240
- parent=None,
241
- completion=completion,
242
- )
71
+ scope=scope,
72
+ metrics=metrics,
243
73
  )
244
74
 
245
- # or create nested metrics otherwise
75
+ # create nested scope otherwise
246
76
  return cls(
247
- ScopeMetrics(
248
- trace_id=trace_id,
249
- scope=name,
250
- logger=logger or current._logger, # pyright: ignore[reportPrivateUsage]
251
- parent=current,
252
- completion=completion,
253
- )
77
+ scope=scope,
78
+ metrics=metrics or current._metrics,
254
79
  )
255
80
 
256
81
  @classmethod
257
- def record[Metric: State](
82
+ def record(
258
83
  cls,
259
- metric: Metric,
84
+ metric: State,
260
85
  /,
261
- *,
262
- merge: Callable[[Metric, Metric], Metric] = lambda lhs, rhs: rhs,
263
86
  ) -> None:
264
87
  try: # catch exceptions - we don't wan't to blow up on metrics
265
- cls._context.get().record(metric, merge=merge)
88
+ metrics: Self = cls._context.get()
89
+
90
+ if metrics._metrics is not None:
91
+ metrics._metrics.record(
92
+ metrics._scope,
93
+ metric,
94
+ )
266
95
 
267
96
  except Exception as exc:
268
- cls.log_error(
97
+ LoggerContext.log_error(
269
98
  "Failed to record metric: %s",
270
99
  type(metric).__qualname__,
271
100
  exception=exc,
272
101
  )
273
102
 
274
- # - LOGS -
275
-
276
- @classmethod
277
- def log_error(
278
- cls,
279
- message: str,
280
- /,
281
- *args: Any,
282
- exception: BaseException | None = None,
283
- ) -> None:
284
- try:
285
- cls._context.get().log(
286
- ERROR,
287
- message,
288
- *args,
289
- exception=exception,
290
- )
291
-
292
- except LookupError:
293
- getLogger().log(
294
- ERROR,
295
- message,
296
- *args,
297
- exc_info=exception,
298
- )
299
-
300
- @classmethod
301
- def log_warning(
302
- cls,
303
- message: str,
304
- /,
305
- *args: Any,
306
- exception: Exception | None = None,
307
- ) -> None:
308
- try:
309
- cls._context.get().log(
310
- WARNING,
311
- message,
312
- *args,
313
- exception=exception,
314
- )
315
-
316
- except LookupError:
317
- getLogger().log(
318
- WARNING,
319
- message,
320
- *args,
321
- exc_info=exception,
322
- )
323
-
324
- @classmethod
325
- def log_info(
326
- cls,
327
- message: str,
328
- /,
329
- *args: Any,
330
- ) -> None:
331
- try:
332
- cls._context.get().log(
333
- INFO,
334
- message,
335
- *args,
336
- )
337
-
338
- except LookupError:
339
- getLogger().log(
340
- INFO,
341
- message,
342
- *args,
343
- )
344
-
345
- @classmethod
346
- def log_debug(
347
- cls,
348
- message: str,
349
- /,
350
- *args: Any,
351
- exception: Exception | None = None,
352
- ) -> None:
353
- try:
354
- cls._context.get().log(
355
- DEBUG,
356
- message,
357
- *args,
358
- exception=exception,
359
- )
360
-
361
- except LookupError:
362
- getLogger().log(
363
- DEBUG,
364
- message,
365
- *args,
366
- exc_info=exception,
367
- )
368
-
369
103
  def __init__(
370
104
  self,
371
- metrics: ScopeMetrics,
105
+ scope: ScopeIdentifier,
106
+ metrics: MetricsHandler | None,
372
107
  ) -> None:
373
- self._metrics: ScopeMetrics = metrics
374
- self._token: Token[ScopeMetrics] | None = None
108
+ self._scope: ScopeIdentifier = scope
109
+ self._metrics: MetricsHandler | None = metrics
375
110
 
376
111
  def __enter__(self) -> None:
377
- assert ( # nosec: B101
378
- self._token is None and not self._metrics._finished # pyright: ignore[reportPrivateUsage]
379
- ), "MetricsContext reentrance is not allowed"
380
- self._token = MetricsContext._context.set(self._metrics)
381
- self._metrics.log(INFO, "Started...")
112
+ assert not hasattr(self, "_token"), "Context reentrance is not allowed" # nosec: B101
113
+ self._token: Token[MetricsContext] = MetricsContext._context.set(self)
114
+ if self._metrics is not None:
115
+ self._metrics.enter_scope(self._scope)
382
116
 
383
117
  def __exit__(
384
118
  self,
@@ -386,10 +120,8 @@ class MetricsContext:
386
120
  exc_val: BaseException | None,
387
121
  exc_tb: TracebackType | None,
388
122
  ) -> None:
389
- assert ( # nosec: B101
390
- self._token is not None
391
- ), "Unbalanced MetricsContext context enter/exit"
123
+ assert hasattr(self, "_token"), "Unbalanced context enter/exit" # nosec: B101
392
124
  MetricsContext._context.reset(self._token)
393
- self._metrics._finish() # pyright: ignore[reportPrivateUsage]
394
- self._token = None
395
- self._metrics.log(INFO, f"...finished after {self._metrics.time:.2f}s")
125
+ del self._token
126
+ if self._metrics is not None:
127
+ self._metrics.exit_scope(self._scope)
haiway/context/state.py CHANGED
@@ -86,9 +86,10 @@ class StateContext:
86
86
  /,
87
87
  ) -> Self:
88
88
  try:
89
+ # update current scope context
89
90
  return cls(state=cls._context.get().updated(state=state))
90
91
 
91
- except LookupError: # create new context as a fallback
92
+ except LookupError: # create root scope when missing
92
93
  return cls(state=ScopeState(state))
93
94
 
94
95
  def __init__(
@@ -96,11 +97,10 @@ class StateContext:
96
97
  state: ScopeState,
97
98
  ) -> None:
98
99
  self._state: ScopeState = state
99
- self._token: Token[ScopeState] | None = None
100
100
 
101
101
  def __enter__(self) -> None:
102
- assert self._token is None, "StateContext reentrance is not allowed" # nosec: B101
103
- self._token = StateContext._context.set(self._state)
102
+ assert not hasattr(self, "_token"), "Context reentrance is not allowed" # nosec: B101
103
+ self._token: Token[ScopeState] = StateContext._context.set(self._state)
104
104
 
105
105
  def __exit__(
106
106
  self,
@@ -108,6 +108,6 @@ class StateContext:
108
108
  exc_val: BaseException | None,
109
109
  exc_tb: TracebackType | None,
110
110
  ) -> None:
111
- assert self._token is not None, "Unbalanced StateContext context exit" # nosec: B101
111
+ assert hasattr(self, "_token"), "Unbalanced context enter/exit" # nosec: B101
112
112
  StateContext._context.reset(self._token)
113
- self._token = None
113
+ del self._token
haiway/context/tasks.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from asyncio import Task, TaskGroup, get_event_loop
2
2
  from collections.abc import Callable, Coroutine
3
- from contextvars import ContextVar, Token, copy_context
3
+ from contextvars import ContextVar, copy_context
4
4
  from types import TracebackType
5
5
  from typing import final
6
6
 
@@ -37,10 +37,9 @@ class TaskGroupContext:
37
37
  self,
38
38
  ) -> None:
39
39
  self._group: TaskGroup = TaskGroup()
40
- self._token: Token[TaskGroup] | None = None
41
40
 
42
41
  async def __aenter__(self) -> None:
43
- assert self._token is None, "TaskGroupContext reentrance is not allowed" # nosec: B101
42
+ assert not hasattr(self, "_token"), "Context reentrance is not allowed" # nosec: B101
44
43
  await self._group.__aenter__()
45
44
  self._token = TaskGroupContext._context.set(self._group)
46
45
 
@@ -50,9 +49,9 @@ class TaskGroupContext:
50
49
  exc_val: BaseException | None,
51
50
  exc_tb: TracebackType | None,
52
51
  ) -> None:
53
- assert self._token is not None, "Unbalanced TaskGroupContext context exit" # nosec: B101
52
+ assert hasattr(self, "_token"), "Unbalanced context enter/exit" # nosec: B101
54
53
  TaskGroupContext._context.reset(self._token)
55
- self._token = None
54
+ del self._token
56
55
 
57
56
  try:
58
57
  await self._group.__aexit__(
@@ -1,5 +1,6 @@
1
1
  from haiway.helpers.asynchrony import asynchronous, wrap_async
2
2
  from haiway.helpers.caching import cache
3
+ from haiway.helpers.metrics import MetricsLogger
3
4
  from haiway.helpers.retries import retry
4
5
  from haiway.helpers.throttling import throttle
5
6
  from haiway.helpers.timeouted import timeout
@@ -7,6 +8,7 @@ from haiway.helpers.tracing import ArgumentsTrace, ResultTrace, traced
7
8
 
8
9
  __all__ = [
9
10
  "ArgumentsTrace",
11
+ "MetricsLogger",
10
12
  "ResultTrace",
11
13
  "asynchronous",
12
14
  "cache",