haiway 0.16.0__py3-none-any.whl → 0.18.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.
Files changed (47) hide show
  1. haiway/__init__.py +18 -18
  2. haiway/context/__init__.py +19 -15
  3. haiway/context/access.py +92 -144
  4. haiway/context/disposables.py +2 -2
  5. haiway/context/identifier.py +4 -5
  6. haiway/context/observability.py +452 -0
  7. haiway/context/state.py +2 -2
  8. haiway/context/tasks.py +1 -3
  9. haiway/context/types.py +2 -2
  10. haiway/helpers/__init__.py +7 -6
  11. haiway/helpers/asynchrony.py +2 -2
  12. haiway/helpers/caching.py +2 -2
  13. haiway/helpers/observability.py +219 -0
  14. haiway/helpers/retries.py +1 -3
  15. haiway/helpers/throttling.py +1 -3
  16. haiway/helpers/timeouted.py +1 -3
  17. haiway/helpers/tracing.py +25 -17
  18. haiway/opentelemetry/__init__.py +3 -0
  19. haiway/opentelemetry/observability.py +420 -0
  20. haiway/state/__init__.py +2 -2
  21. haiway/state/attributes.py +2 -2
  22. haiway/state/path.py +1 -3
  23. haiway/state/requirement.py +1 -3
  24. haiway/state/structure.py +161 -30
  25. haiway/state/validation.py +2 -2
  26. haiway/types/__init__.py +2 -2
  27. haiway/types/default.py +2 -2
  28. haiway/types/frozen.py +1 -3
  29. haiway/types/missing.py +2 -2
  30. haiway/utils/__init__.py +2 -2
  31. haiway/utils/always.py +2 -2
  32. haiway/utils/collections.py +2 -2
  33. haiway/utils/env.py +2 -2
  34. haiway/utils/freezing.py +1 -3
  35. haiway/utils/logs.py +1 -3
  36. haiway/utils/mimic.py +1 -3
  37. haiway/utils/noop.py +2 -2
  38. haiway/utils/queue.py +1 -3
  39. haiway/utils/stream.py +1 -3
  40. {haiway-0.16.0.dist-info → haiway-0.18.0.dist-info}/METADATA +9 -5
  41. haiway-0.18.0.dist-info/RECORD +44 -0
  42. haiway/context/logging.py +0 -242
  43. haiway/context/metrics.py +0 -214
  44. haiway/helpers/metrics.py +0 -501
  45. haiway-0.16.0.dist-info/RECORD +0 -43
  46. {haiway-0.16.0.dist-info → haiway-0.18.0.dist-info}/WHEEL +0 -0
  47. {haiway-0.16.0.dist-info → haiway-0.18.0.dist-info}/licenses/LICENSE +0 -0
haiway/context/metrics.py DELETED
@@ -1,214 +0,0 @@
1
- from contextvars import ContextVar, Token
2
- from types import TracebackType
3
- from typing import Any, Protocol, Self, final, runtime_checkable
4
-
5
- from haiway.context.identifier import ScopeIdentifier
6
- from haiway.context.logging import LoggerContext
7
- from haiway.state import State
8
-
9
- __all__ = [
10
- "MetricsContext",
11
- "MetricsHandler",
12
- "MetricsReading",
13
- "MetricsRecording",
14
- "MetricsScopeEntering",
15
- "MetricsScopeExiting",
16
- ]
17
-
18
-
19
- @runtime_checkable
20
- class MetricsRecording(Protocol):
21
- def __call__(
22
- self,
23
- scope: ScopeIdentifier,
24
- /,
25
- metric: State,
26
- ) -> None: ...
27
-
28
-
29
- @runtime_checkable
30
- class MetricsReading(Protocol):
31
- async def __call__[Metric: State](
32
- self,
33
- scope: ScopeIdentifier,
34
- /,
35
- *,
36
- metric: type[Metric],
37
- merged: bool,
38
- ) -> Metric | None: ...
39
-
40
-
41
- @runtime_checkable
42
- class MetricsScopeEntering(Protocol):
43
- def __call__[Metric: State](
44
- self,
45
- scope: ScopeIdentifier,
46
- /,
47
- ) -> None: ...
48
-
49
-
50
- @runtime_checkable
51
- class MetricsScopeExiting(Protocol):
52
- def __call__[Metric: State](
53
- self,
54
- scope: ScopeIdentifier,
55
- /,
56
- ) -> None: ...
57
-
58
-
59
- class MetricsHandler(State):
60
- record: MetricsRecording
61
- read: MetricsReading
62
- enter_scope: MetricsScopeEntering
63
- exit_scope: MetricsScopeExiting
64
-
65
-
66
- @final
67
- class MetricsContext:
68
- _context = ContextVar[Self]("MetricsContext")
69
-
70
- @classmethod
71
- def scope(
72
- cls,
73
- scope: ScopeIdentifier,
74
- /,
75
- *,
76
- metrics: MetricsHandler | None,
77
- ) -> Self:
78
- current: Self
79
- try: # check for current scope
80
- current = cls._context.get()
81
-
82
- except LookupError:
83
- # create root scope when missing
84
- return cls(
85
- scope=scope,
86
- metrics=metrics,
87
- )
88
-
89
- # create nested scope otherwise
90
- return cls(
91
- scope=scope,
92
- metrics=metrics or current._metrics,
93
- )
94
-
95
- @classmethod
96
- def record(
97
- cls,
98
- metric: State,
99
- /,
100
- ) -> None:
101
- try: # catch exceptions - we don't wan't to blow up on metrics
102
- metrics: Self = cls._context.get()
103
-
104
- if metrics._metrics is not None:
105
- metrics._metrics.record(
106
- metrics._scope,
107
- metric,
108
- )
109
-
110
- except Exception as exc:
111
- LoggerContext.log_error(
112
- "Failed to record metric: %s",
113
- type(metric).__qualname__,
114
- exception=exc,
115
- )
116
-
117
- @classmethod
118
- async def read[Metric: State](
119
- cls,
120
- metric: type[Metric],
121
- /,
122
- merged: bool,
123
- ) -> Metric | None:
124
- try: # catch exceptions - we don't wan't to blow up on metrics
125
- metrics: Self = cls._context.get()
126
-
127
- if metrics._metrics is not None:
128
- return await metrics._metrics.read(
129
- metrics._scope,
130
- metric=metric,
131
- merged=merged,
132
- )
133
-
134
- except Exception as exc:
135
- LoggerContext.log_error(
136
- "Failed to read metric: %s",
137
- metric.__qualname__,
138
- exception=exc,
139
- )
140
-
141
- __slots__ = (
142
- "_metrics",
143
- "_scope",
144
- "_token",
145
- )
146
-
147
- def __init__(
148
- self,
149
- scope: ScopeIdentifier,
150
- metrics: MetricsHandler | None,
151
- ) -> None:
152
- self._scope: ScopeIdentifier
153
- object.__setattr__(
154
- self,
155
- "_scope",
156
- scope,
157
- )
158
- self._metrics: MetricsHandler | None
159
- object.__setattr__(
160
- self,
161
- "_metrics",
162
- metrics,
163
- )
164
- self._token: Token[MetricsContext] | None
165
- object.__setattr__(
166
- self,
167
- "_token",
168
- None,
169
- )
170
-
171
- def __setattr__(
172
- self,
173
- name: str,
174
- value: Any,
175
- ) -> Any:
176
- raise AttributeError(
177
- f"Can't modify immutable {self.__class__.__qualname__},"
178
- f" attribute - '{name}' cannot be modified"
179
- )
180
-
181
- def __delattr__(
182
- self,
183
- name: str,
184
- ) -> None:
185
- raise AttributeError(
186
- f"Can't modify immutable {self.__class__.__qualname__},"
187
- f" attribute - '{name}' cannot be deleted"
188
- )
189
-
190
- def __enter__(self) -> None:
191
- assert self._token is None, "Context reentrance is not allowed" # nosec: B101
192
- object.__setattr__(
193
- self,
194
- "_token",
195
- MetricsContext._context.set(self),
196
- )
197
- if self._metrics is not None:
198
- self._metrics.enter_scope(self._scope)
199
-
200
- def __exit__(
201
- self,
202
- exc_type: type[BaseException] | None,
203
- exc_val: BaseException | None,
204
- exc_tb: TracebackType | None,
205
- ) -> None:
206
- assert self._token is not None, "Unbalanced context enter/exit" # nosec: B101
207
- MetricsContext._context.reset(self._token)
208
- object.__setattr__(
209
- self,
210
- "_token",
211
- None,
212
- )
213
- if self._metrics is not None:
214
- self._metrics.exit_scope(self._scope)
haiway/helpers/metrics.py DELETED
@@ -1,501 +0,0 @@
1
- from collections.abc import Sequence
2
- from itertools import chain
3
- from time import monotonic
4
- from typing import Any, Self, cast, final, overload
5
-
6
- from haiway.context import MetricsHandler, ScopeIdentifier, ctx
7
- from haiway.state import State
8
- from haiway.types import MISSING
9
-
10
- __all_ = [
11
- "MetricsLogger",
12
- "MetricsHolder",
13
- ]
14
-
15
-
16
- class MetricsScopeStore:
17
- __slots__ = (
18
- "allow_exit",
19
- "entered",
20
- "exited",
21
- "identifier",
22
- "metrics",
23
- "nested",
24
- )
25
-
26
- def __init__(
27
- self,
28
- identifier: ScopeIdentifier,
29
- /,
30
- ) -> None:
31
- self.identifier: ScopeIdentifier = identifier
32
- self.entered: float = monotonic()
33
- self.metrics: dict[type[State], State] = {}
34
- self.exited: float | None = None
35
- self.allow_exit: bool = False
36
- self.nested: list[MetricsScopeStore] = []
37
-
38
- @property
39
- def time(self) -> float:
40
- return (self.exited or monotonic()) - self.entered
41
-
42
- @property
43
- def finished(self) -> float:
44
- return self.exited is not None and all(nested.finished for nested in self.nested)
45
-
46
- @overload
47
- def merged[Metric: State](
48
- self,
49
- ) -> Sequence[State]: ...
50
-
51
- @overload
52
- def merged[Metric: State](
53
- self,
54
- metric: type[Metric],
55
- ) -> Metric | None: ...
56
-
57
- def merged[Metric: State](
58
- self,
59
- metric: type[Metric] | None = None,
60
- ) -> Sequence[State] | Metric | None:
61
- if metric is None:
62
- merged_metrics: dict[type[State], State] = dict(self.metrics)
63
- for nested in chain.from_iterable(nested.merged() for nested in self.nested):
64
- metric_type: type[State] = type(nested)
65
- current: State | None = merged_metrics.get(metric_type)
66
-
67
- if current is None:
68
- merged_metrics[metric_type] = nested
69
- continue # keep going
70
-
71
- if hasattr(current, "__add__"):
72
- merged_metrics[metric_type] = current.__add__(nested) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
73
- assert isinstance(merged_metrics[metric_type], State) # nosec: B101
74
- continue # keep going
75
-
76
- break # we have multiple value without a way to merge
77
-
78
- return tuple(merged_metrics.values())
79
-
80
- else:
81
- merged_metric: State | None = self.metrics.get(metric)
82
- for nested in self.nested:
83
- nested_metric: Metric | None = nested.merged(metric)
84
- if nested_metric is None:
85
- continue # skip missing
86
-
87
- if merged_metric is None:
88
- merged_metric = nested_metric
89
- continue # keep going
90
-
91
- if hasattr(merged_metric, "__add__"):
92
- merged_metric = merged_metric.__add__(nested_metric) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType]
93
- assert isinstance(merged_metric, metric) # nosec: B101
94
- continue # keep going
95
-
96
- break # we have multiple value without a way to merge
97
-
98
- return cast(Metric | None, merged_metric)
99
-
100
-
101
- @final
102
- class MetricsHolder:
103
- @classmethod
104
- def handler(cls) -> MetricsHandler:
105
- store_handler: Self = cls()
106
- return MetricsHandler(
107
- record=store_handler.record,
108
- read=store_handler.read,
109
- enter_scope=store_handler.enter_scope,
110
- exit_scope=store_handler.exit_scope,
111
- )
112
-
113
- __slots__ = (
114
- "root_scope",
115
- "scopes",
116
- )
117
-
118
- def __init__(self) -> None:
119
- self.root_scope: ScopeIdentifier | None = None
120
- self.scopes: dict[str, MetricsScopeStore] = {}
121
-
122
- def record(
123
- self,
124
- scope: ScopeIdentifier,
125
- /,
126
- metric: State,
127
- ) -> None:
128
- assert self.root_scope is not None # nosec: B101
129
- assert scope.scope_id in self.scopes # nosec: B101
130
-
131
- metric_type: type[State] = type(metric)
132
- metrics: dict[type[State], State] = self.scopes[scope.scope_id].metrics
133
- if (current := metrics.get(metric_type)) and hasattr(current, "__add__"):
134
- metrics[type(metric)] = current.__add__(metric) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
135
-
136
- metrics[type(metric)] = metric
137
-
138
- async def read[Metric: State](
139
- self,
140
- scope: ScopeIdentifier,
141
- /,
142
- *,
143
- metric: type[Metric],
144
- merged: bool,
145
- ) -> Metric | None:
146
- assert self.root_scope is not None # nosec: B101
147
- assert scope.scope_id in self.scopes # nosec: B101
148
-
149
- if merged:
150
- return self.scopes[scope.scope_id].merged(metric)
151
-
152
- else:
153
- return cast(Metric | None, self.scopes[scope.scope_id].metrics.get(metric))
154
-
155
- def enter_scope[Metric: State](
156
- self,
157
- scope: ScopeIdentifier,
158
- /,
159
- ) -> None:
160
- assert scope.scope_id not in self.scopes # nosec: B101
161
- scope_metrics = MetricsScopeStore(scope)
162
- self.scopes[scope.scope_id] = scope_metrics
163
-
164
- if self.root_scope is None:
165
- self.root_scope = scope
166
-
167
- else:
168
- for key in self.scopes.keys():
169
- if key == scope.parent_id:
170
- self.scopes[key].nested.append(scope_metrics)
171
- return
172
-
173
- ctx.log_debug(
174
- "Attempting to enter nested scope metrics without entering its parent first"
175
- )
176
-
177
- def exit_scope[Metric: State](
178
- self,
179
- scope: ScopeIdentifier,
180
- /,
181
- ) -> None:
182
- assert self.root_scope is not None # nosec: B101
183
- assert scope.scope_id in self.scopes # nosec: B101
184
-
185
- self.scopes[scope.scope_id].allow_exit = True
186
-
187
- if not all(nested.exited for nested in self.scopes[scope.scope_id].nested):
188
- return # not completed yet
189
-
190
- self.scopes[scope.scope_id].exited = monotonic()
191
-
192
- if scope != self.root_scope and self.scopes[scope.parent_id].allow_exit:
193
- self.exit_scope(self.scopes[scope.parent_id].identifier)
194
-
195
-
196
- @final
197
- class MetricsLogger:
198
- @classmethod
199
- def handler(
200
- cls,
201
- items_limit: int | None = None,
202
- redact_content: bool = False,
203
- ) -> MetricsHandler:
204
- logger_handler: Self = cls(
205
- items_limit=items_limit,
206
- redact_content=redact_content,
207
- )
208
- return MetricsHandler(
209
- record=logger_handler.record,
210
- read=logger_handler.read,
211
- enter_scope=logger_handler.enter_scope,
212
- exit_scope=logger_handler.exit_scope,
213
- )
214
-
215
- __slots__ = (
216
- "items_limit",
217
- "redact_content",
218
- "root_scope",
219
- "scopes",
220
- )
221
-
222
- def __init__(
223
- self,
224
- items_limit: int | None,
225
- redact_content: bool,
226
- ) -> None:
227
- self.root_scope: ScopeIdentifier | None = None
228
- self.scopes: dict[str, MetricsScopeStore] = {}
229
- self.items_limit: int | None = items_limit
230
- self.redact_content: bool = redact_content
231
-
232
- def record(
233
- self,
234
- scope: ScopeIdentifier,
235
- /,
236
- metric: State,
237
- ) -> None:
238
- assert self.root_scope is not None # nosec: B101
239
- assert scope.scope_id in self.scopes # nosec: B101
240
-
241
- metric_type: type[State] = type(metric)
242
- metrics: dict[type[State], State] = self.scopes[scope.scope_id].metrics
243
- if (current := metrics.get(metric_type)) and hasattr(current, "__add__"):
244
- metrics[type(metric)] = current.__add__(metric) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
245
-
246
- metrics[type(metric)] = metric
247
- if log := _state_log(
248
- metric,
249
- list_items_limit=self.items_limit,
250
- redact_content=self.redact_content,
251
- ):
252
- ctx.log_debug(f"Recorded metric:\n⎡ {type(metric).__qualname__}:{log}\n⌊")
253
-
254
- async def read[Metric: State](
255
- self,
256
- scope: ScopeIdentifier,
257
- /,
258
- *,
259
- metric: type[Metric],
260
- merged: bool,
261
- ) -> Metric | None:
262
- assert self.root_scope is not None # nosec: B101
263
- assert scope.scope_id in self.scopes # nosec: B101
264
-
265
- if merged:
266
- return self.scopes[scope.scope_id].merged(metric)
267
-
268
- else:
269
- return cast(Metric | None, self.scopes[scope.scope_id].metrics.get(metric))
270
-
271
- def enter_scope[Metric: State](
272
- self,
273
- scope: ScopeIdentifier,
274
- /,
275
- ) -> None:
276
- assert scope.scope_id not in self.scopes # nosec: B101
277
- scope_metrics = MetricsScopeStore(scope)
278
- self.scopes[scope.scope_id] = scope_metrics
279
-
280
- if self.root_scope is None:
281
- self.root_scope = scope
282
-
283
- else:
284
- for key in self.scopes.keys():
285
- if key == scope.parent_id:
286
- self.scopes[key].nested.append(scope_metrics)
287
- return
288
-
289
- ctx.log_debug(
290
- "Attempting to enter nested scope metrics without entering its parent first"
291
- )
292
-
293
- def exit_scope[Metric: State](
294
- self,
295
- scope: ScopeIdentifier,
296
- /,
297
- ) -> None:
298
- assert self.root_scope is not None # nosec: B101
299
- assert scope.scope_id in self.scopes # nosec: B101
300
-
301
- self.scopes[scope.scope_id].allow_exit = True
302
-
303
- if not all(nested.exited for nested in self.scopes[scope.scope_id].nested):
304
- return # not completed yet
305
-
306
- self.scopes[scope.scope_id].exited = monotonic()
307
-
308
- if scope != self.root_scope and self.scopes[scope.parent_id].allow_exit:
309
- self.exit_scope(self.scopes[scope.parent_id].identifier)
310
-
311
- elif scope == self.root_scope and self.scopes[self.root_scope.scope_id].finished:
312
- if log := _tree_log(
313
- self.scopes[scope.scope_id],
314
- list_items_limit=self.items_limit,
315
- redact_content=self.redact_content,
316
- ):
317
- ctx.log_debug(f"Metrics summary:\n{log}")
318
-
319
-
320
- def _tree_log(
321
- metrics: MetricsScopeStore,
322
- list_items_limit: int | None,
323
- redact_content: bool,
324
- ) -> str:
325
- log: str = (
326
- f"⎡ @{metrics.identifier.label} [{metrics.identifier.scope_id}]({metrics.time:.2f}s):"
327
- )
328
-
329
- for metric in metrics.merged():
330
- if type(metric) not in metrics.metrics:
331
- continue # skip metrics not available in this scope
332
-
333
- metric_log: str = ""
334
- for key, value in vars(metric).items():
335
- if value_log := _value_log(
336
- value,
337
- list_items_limit=list_items_limit,
338
- redact_content=redact_content,
339
- ):
340
- metric_log += f"\n├ {key}: {value_log}"
341
-
342
- else:
343
- continue # skip empty values
344
-
345
- if not metric_log:
346
- continue # skip empty logs
347
-
348
- log += f"\n⎡ •{type(metric).__qualname__}:{metric_log.replace('\n', '\n| ')}\n⌊"
349
-
350
- for nested in metrics.nested:
351
- nested_log: str = _tree_log(
352
- nested,
353
- list_items_limit=list_items_limit,
354
- redact_content=redact_content,
355
- )
356
-
357
- log += f"\n\n{nested_log}"
358
-
359
- return log.strip().replace("\n", "\n| ") + "\n⌊"
360
-
361
-
362
- def _state_log(
363
- value: State,
364
- /,
365
- list_items_limit: int | None,
366
- redact_content: bool,
367
- ) -> str | None:
368
- state_log: str = ""
369
- for key, element in vars(value).items():
370
- element_log: str | None = _value_log(
371
- element,
372
- list_items_limit=list_items_limit,
373
- redact_content=redact_content,
374
- )
375
-
376
- if element_log:
377
- state_log += f"\n├ {key}: {element_log}"
378
-
379
- else:
380
- continue # skip empty logs
381
-
382
- if state_log:
383
- return state_log
384
-
385
- else:
386
- return None # skip empty logs
387
-
388
-
389
- def _dict_log(
390
- value: dict[Any, Any],
391
- /,
392
- list_items_limit: int | None,
393
- redact_content: bool,
394
- ) -> str | None:
395
- dict_log: str = ""
396
- for key, element in value.items():
397
- element_log: str | None = _value_log(
398
- element,
399
- list_items_limit=list_items_limit,
400
- redact_content=redact_content,
401
- )
402
- if element_log:
403
- dict_log += f"\n[{key}]: {element_log}"
404
-
405
- else:
406
- continue # skip empty logs
407
-
408
- if dict_log:
409
- return dict_log.replace("\n", "\n| ")
410
-
411
- else:
412
- return None # skip empty logs
413
-
414
-
415
- def _list_log(
416
- value: list[Any],
417
- /,
418
- list_items_limit: int | None,
419
- redact_content: bool,
420
- ) -> str | None:
421
- list_log: str = ""
422
- enumerated: list[tuple[int, Any]] = list(enumerate(value))
423
- if list_items_limit:
424
- if list_items_limit > 0:
425
- enumerated = enumerated[:list_items_limit]
426
-
427
- else:
428
- enumerated = enumerated[list_items_limit:]
429
-
430
- for idx, element in enumerated:
431
- element_log: str | None = _value_log(
432
- element,
433
- list_items_limit=list_items_limit,
434
- redact_content=redact_content,
435
- )
436
- if element_log:
437
- list_log += f"\n[{idx}] {element_log}"
438
-
439
- else:
440
- continue # skip empty logs
441
-
442
- if list_log:
443
- return list_log.replace("\n", "\n| ")
444
-
445
- else:
446
- return None # skip empty logs
447
-
448
-
449
- def _raw_value_log(
450
- value: Any,
451
- /,
452
- redact_content: bool,
453
- ) -> str | None:
454
- if value is MISSING:
455
- return None # skip missing
456
-
457
- if redact_content:
458
- return "[redacted]"
459
-
460
- elif isinstance(value, str):
461
- return f'"{value}"'.replace("\n", "\n| ")
462
-
463
- else:
464
- return str(value).strip().replace("\n", "\n| ")
465
-
466
-
467
- def _value_log(
468
- value: Any,
469
- /,
470
- list_items_limit: int | None,
471
- redact_content: bool,
472
- ) -> str | None:
473
- # try unpack dicts
474
- if isinstance(value, dict):
475
- return _dict_log(
476
- cast(dict[Any, Any], value),
477
- list_items_limit=list_items_limit,
478
- redact_content=redact_content,
479
- )
480
-
481
- # try unpack lists
482
- elif isinstance(value, list):
483
- return _list_log(
484
- cast(list[Any], value),
485
- list_items_limit=list_items_limit,
486
- redact_content=redact_content,
487
- )
488
-
489
- # try unpack state
490
- elif isinstance(value, State):
491
- return _state_log(
492
- value,
493
- list_items_limit=list_items_limit,
494
- redact_content=redact_content,
495
- )
496
-
497
- else:
498
- return _raw_value_log(
499
- value,
500
- redact_content=redact_content,
501
- )