haiway 0.10.14__py3-none-any.whl → 0.10.16__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 (43) hide show
  1. haiway/__init__.py +111 -0
  2. haiway/context/__init__.py +27 -0
  3. haiway/context/access.py +615 -0
  4. haiway/context/disposables.py +78 -0
  5. haiway/context/identifier.py +92 -0
  6. haiway/context/logging.py +176 -0
  7. haiway/context/metrics.py +165 -0
  8. haiway/context/state.py +113 -0
  9. haiway/context/tasks.py +64 -0
  10. haiway/context/types.py +12 -0
  11. haiway/helpers/__init__.py +21 -0
  12. haiway/helpers/asynchrony.py +225 -0
  13. haiway/helpers/caching.py +326 -0
  14. haiway/helpers/metrics.py +459 -0
  15. haiway/helpers/retries.py +223 -0
  16. haiway/helpers/throttling.py +133 -0
  17. haiway/helpers/timeouted.py +112 -0
  18. haiway/helpers/tracing.py +137 -0
  19. haiway/py.typed +0 -0
  20. haiway/state/__init__.py +12 -0
  21. haiway/state/attributes.py +747 -0
  22. haiway/state/path.py +524 -0
  23. haiway/state/requirement.py +229 -0
  24. haiway/state/structure.py +414 -0
  25. haiway/state/validation.py +468 -0
  26. haiway/types/__init__.py +14 -0
  27. haiway/types/default.py +108 -0
  28. haiway/types/frozen.py +5 -0
  29. haiway/types/missing.py +95 -0
  30. haiway/utils/__init__.py +28 -0
  31. haiway/utils/always.py +61 -0
  32. haiway/utils/collections.py +185 -0
  33. haiway/utils/env.py +230 -0
  34. haiway/utils/freezing.py +28 -0
  35. haiway/utils/logs.py +57 -0
  36. haiway/utils/mimic.py +77 -0
  37. haiway/utils/noop.py +24 -0
  38. haiway/utils/queue.py +82 -0
  39. {haiway-0.10.14.dist-info → haiway-0.10.16.dist-info}/METADATA +1 -1
  40. haiway-0.10.16.dist-info/RECORD +42 -0
  41. haiway-0.10.14.dist-info/RECORD +0 -4
  42. {haiway-0.10.14.dist-info → haiway-0.10.16.dist-info}/WHEEL +0 -0
  43. {haiway-0.10.14.dist-info → haiway-0.10.16.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,615 @@
1
+ from asyncio import CancelledError, Task, current_task, iscoroutinefunction
2
+ from collections.abc import (
3
+ AsyncGenerator,
4
+ AsyncIterator,
5
+ Callable,
6
+ Coroutine,
7
+ Iterable,
8
+ )
9
+ from contextvars import Context, copy_context
10
+ from logging import Logger
11
+ from types import TracebackType
12
+ from typing import Any, final, overload
13
+
14
+ from haiway.context.disposables import Disposable, Disposables
15
+ from haiway.context.identifier import ScopeIdentifier
16
+ from haiway.context.logging import LoggerContext
17
+ from haiway.context.metrics import MetricsContext, MetricsHandler
18
+ from haiway.context.state import StateContext
19
+ from haiway.context.tasks import TaskGroupContext
20
+ from haiway.state import State
21
+ from haiway.utils import freeze, mimic_function
22
+
23
+ __all__ = [
24
+ "ctx",
25
+ ]
26
+
27
+
28
+ @final
29
+ class ScopeContext:
30
+ def __init__(
31
+ self,
32
+ label: str,
33
+ logger: Logger | None,
34
+ state: tuple[State, ...],
35
+ disposables: Disposables | None,
36
+ metrics: MetricsHandler | None,
37
+ ) -> None:
38
+ self._identifier: ScopeIdentifier = ScopeIdentifier.scope(label)
39
+ self._logger_context: LoggerContext = LoggerContext(
40
+ self._identifier,
41
+ logger=logger,
42
+ )
43
+ self._task_group_context: TaskGroupContext = TaskGroupContext()
44
+ # postponing state creation to include disposables state when prepared
45
+ self._state_context: StateContext
46
+ self._state: tuple[State, ...] = state
47
+ self._disposables: Disposables | None = disposables
48
+ # pre-building metrics context to ensure nested context registering
49
+ self._metrics_context: MetricsContext = MetricsContext.scope(
50
+ self._identifier,
51
+ metrics=metrics,
52
+ )
53
+
54
+ freeze(self)
55
+
56
+ def __enter__(self) -> str:
57
+ assert self._disposables is None, "Can't enter synchronous context with disposables" # nosec: B101
58
+ self._identifier.__enter__()
59
+ self._logger_context.__enter__()
60
+ self._state_context = StateContext.updated(self._state)
61
+ self._state_context.__enter__()
62
+ self._metrics_context.__enter__()
63
+
64
+ return self._identifier.trace_id
65
+
66
+ def __exit__(
67
+ self,
68
+ exc_type: type[BaseException] | None,
69
+ exc_val: BaseException | None,
70
+ exc_tb: TracebackType | None,
71
+ ) -> None:
72
+ self._metrics_context.__exit__(
73
+ exc_type=exc_type,
74
+ exc_val=exc_val,
75
+ exc_tb=exc_tb,
76
+ )
77
+
78
+ self._state_context.__exit__(
79
+ exc_type=exc_type,
80
+ exc_val=exc_val,
81
+ exc_tb=exc_tb,
82
+ )
83
+
84
+ self._logger_context.__exit__(
85
+ exc_type=exc_type,
86
+ exc_val=exc_val,
87
+ exc_tb=exc_tb,
88
+ )
89
+
90
+ self._identifier.__exit__(
91
+ exc_type=exc_type,
92
+ exc_val=exc_val,
93
+ exc_tb=exc_tb,
94
+ )
95
+
96
+ async def __aenter__(self) -> str:
97
+ self._identifier.__enter__()
98
+ self._logger_context.__enter__()
99
+ await self._task_group_context.__aenter__()
100
+
101
+ if self._disposables is not None:
102
+ self._state_context = StateContext.updated(
103
+ (*self._state, *await self._disposables.__aenter__())
104
+ )
105
+
106
+ else:
107
+ self._state_context = StateContext.updated(self._state)
108
+
109
+ self._state_context.__enter__()
110
+ self._metrics_context.__enter__()
111
+
112
+ return self._identifier.trace_id
113
+
114
+ async def __aexit__(
115
+ self,
116
+ exc_type: type[BaseException] | None,
117
+ exc_val: BaseException | None,
118
+ exc_tb: TracebackType | None,
119
+ ) -> None:
120
+ if self._disposables is not None:
121
+ await self._disposables.__aexit__(
122
+ exc_type=exc_type,
123
+ exc_val=exc_val,
124
+ exc_tb=exc_tb,
125
+ )
126
+
127
+ await self._task_group_context.__aexit__(
128
+ exc_type=exc_type,
129
+ exc_val=exc_val,
130
+ exc_tb=exc_tb,
131
+ )
132
+
133
+ self._metrics_context.__exit__(
134
+ exc_type=exc_type,
135
+ exc_val=exc_val,
136
+ exc_tb=exc_tb,
137
+ )
138
+
139
+ self._state_context.__exit__(
140
+ exc_type=exc_type,
141
+ exc_val=exc_val,
142
+ exc_tb=exc_tb,
143
+ )
144
+
145
+ self._logger_context.__exit__(
146
+ exc_type=exc_type,
147
+ exc_val=exc_val,
148
+ exc_tb=exc_tb,
149
+ )
150
+
151
+ self._identifier.__exit__(
152
+ exc_type=exc_type,
153
+ exc_val=exc_val,
154
+ exc_tb=exc_tb,
155
+ )
156
+
157
+ @overload
158
+ def __call__[Result, **Arguments](
159
+ self,
160
+ function: Callable[Arguments, Coroutine[None, None, Result]],
161
+ ) -> Callable[Arguments, Coroutine[None, None, Result]]: ...
162
+
163
+ @overload
164
+ def __call__[Result, **Arguments](
165
+ self,
166
+ function: Callable[Arguments, Result],
167
+ ) -> Callable[Arguments, Result]: ...
168
+
169
+ def __call__[Result, **Arguments](
170
+ self,
171
+ function: Callable[Arguments, Coroutine[None, None, Result]] | Callable[Arguments, Result],
172
+ ) -> Callable[Arguments, Coroutine[None, None, Result]] | Callable[Arguments, Result]:
173
+ if iscoroutinefunction(function):
174
+
175
+ async def async_context(
176
+ *args: Arguments.args,
177
+ **kwargs: Arguments.kwargs,
178
+ ) -> Result:
179
+ async with self:
180
+ return await function(*args, **kwargs)
181
+
182
+ return mimic_function(function, within=async_context)
183
+
184
+ else:
185
+
186
+ def sync_context(
187
+ *args: Arguments.args,
188
+ **kwargs: Arguments.kwargs,
189
+ ) -> Result:
190
+ with self:
191
+ return function(*args, **kwargs) # pyright: ignore[reportReturnType]
192
+
193
+ return mimic_function(function, within=sync_context) # pyright: ignore[reportReturnType]
194
+
195
+
196
+ @final
197
+ class ctx:
198
+ @staticmethod
199
+ def trace_id() -> str:
200
+ """
201
+ Get the current context trace identifier.
202
+ """
203
+
204
+ return ScopeIdentifier.current_trace_id()
205
+
206
+ @staticmethod
207
+ def scope(
208
+ label: str,
209
+ /,
210
+ *state: State,
211
+ disposables: Disposables | Iterable[Disposable] | None = None,
212
+ logger: Logger | None = None,
213
+ metrics: MetricsHandler | None = None,
214
+ ) -> ScopeContext:
215
+ """
216
+ Prepare scope context with given parameters. When called within an existing context\
217
+ it becomes nested with current context as its parent.
218
+
219
+ Parameters
220
+ ----------
221
+ label: str
222
+ name of the scope context
223
+
224
+ *state: State | Disposable
225
+ state propagated within the scope context, will be merged with current state by\
226
+ replacing current with provided on conflict.
227
+
228
+ disposables: Disposables | Iterable[Disposable] | None
229
+ disposables consumed within the context when entered. Produced state will automatically\
230
+ be added to the scope state. Using asynchronous context is required if any disposables\
231
+ were provided.
232
+
233
+ logger: Logger | None
234
+ logger used within the scope context, when not provided current logger will be used\
235
+ if any, otherwise the logger with the scope name will be requested.
236
+
237
+ metrics_store: MetricsStore | None = None
238
+ metrics storage solution responsible for recording and storing metrics.\
239
+ Metrics recroding will be ignored if storage is not provided.
240
+ Assigning metrics_store within existing context will result in an error.
241
+
242
+ Returns
243
+ -------
244
+ ScopeContext
245
+ context object intended to enter context manager with.\
246
+ context manager will provide trace_id of current context.
247
+ """
248
+
249
+ resolved_disposables: Disposables | None
250
+ match disposables:
251
+ case None:
252
+ resolved_disposables = None
253
+
254
+ case Disposables() as disposables:
255
+ resolved_disposables = disposables
256
+
257
+ case iterable:
258
+ resolved_disposables = Disposables(*iterable)
259
+
260
+ return ScopeContext(
261
+ label=label,
262
+ logger=logger,
263
+ state=state,
264
+ disposables=resolved_disposables,
265
+ metrics=metrics,
266
+ )
267
+
268
+ @staticmethod
269
+ def updated(
270
+ *state: State,
271
+ ) -> StateContext:
272
+ """
273
+ Update scope context with given state. When called within an existing context\
274
+ it becomes nested with current context as its predecessor.
275
+
276
+ Parameters
277
+ ----------
278
+ *state: State
279
+ state propagated within the updated scope context, will be merged with current if any\
280
+ by replacing current with provided on conflict
281
+
282
+ Returns
283
+ -------
284
+ StateContext
285
+ state part of context object intended to enter context manager with it
286
+ """
287
+
288
+ return StateContext.updated(state)
289
+
290
+ @staticmethod
291
+ def spawn[Result, **Arguments](
292
+ function: Callable[Arguments, Coroutine[None, None, Result]],
293
+ /,
294
+ *args: Arguments.args,
295
+ **kwargs: Arguments.kwargs,
296
+ ) -> Task[Result]:
297
+ """
298
+ Spawn an async task within current scope context task group. When called outside of context\
299
+ it will spawn detached task instead.
300
+
301
+ Parameters
302
+ ----------
303
+ function: Callable[Arguments, Coroutine[None, None, Result]]
304
+ function to be called within the task group
305
+
306
+ *args: Arguments.args
307
+ positional arguments passed to function call
308
+
309
+ **kwargs: Arguments.kwargs
310
+ keyword arguments passed to function call
311
+
312
+ Returns
313
+ -------
314
+ Task[Result]
315
+ task for tracking function execution and result
316
+ """
317
+
318
+ return TaskGroupContext.run(function, *args, **kwargs)
319
+
320
+ @staticmethod
321
+ def stream[Result, **Arguments](
322
+ source: Callable[Arguments, AsyncGenerator[Result, None]],
323
+ /,
324
+ *args: Arguments.args,
325
+ **kwargs: Arguments.kwargs,
326
+ ) -> AsyncIterator[Result]:
327
+ """
328
+ Stream results produced by a generator within the proper context state.
329
+
330
+ Parameters
331
+ ----------
332
+ source: Callable[Arguments, AsyncGenerator[Result, None]]
333
+ generator streamed as the result
334
+
335
+ *args: Arguments.args
336
+ positional arguments passed to generator call
337
+
338
+ **kwargs: Arguments.kwargs
339
+ keyword arguments passed to generator call
340
+
341
+ Returns
342
+ -------
343
+ AsyncIterator[Result]
344
+ iterator for accessing generated results
345
+ """
346
+
347
+ # prepare context snapshot
348
+ context_snapshot: Context = copy_context()
349
+
350
+ # prepare nested context
351
+ streaming_context: ScopeContext = ctx.scope(
352
+ getattr(
353
+ source,
354
+ "__name__",
355
+ "streaming",
356
+ )
357
+ )
358
+
359
+ async def generator() -> AsyncGenerator[Result, None]:
360
+ async with streaming_context:
361
+ async for result in source(*args, **kwargs):
362
+ yield result
363
+
364
+ # finally return it as an iterator
365
+ return context_snapshot.run(generator)
366
+
367
+ @staticmethod
368
+ def check_cancellation() -> None:
369
+ """
370
+ Check if current asyncio task is cancelled, raises CancelledError if so.
371
+ """
372
+
373
+ if (task := current_task()) and task.cancelled():
374
+ raise CancelledError()
375
+
376
+ @staticmethod
377
+ def cancel() -> None:
378
+ """
379
+ Cancel current asyncio task
380
+ """
381
+
382
+ if task := current_task():
383
+ task.cancel()
384
+
385
+ else:
386
+ raise RuntimeError("Attempting to cancel context out of asyncio task")
387
+
388
+ @staticmethod
389
+ def state[StateType: State](
390
+ state: type[StateType],
391
+ /,
392
+ default: StateType | None = None,
393
+ ) -> StateType:
394
+ """
395
+ Access current scope context state by its type. If there is no matching state defined\
396
+ default value will be created if able, an exception will raise otherwise.
397
+
398
+ Parameters
399
+ ----------
400
+ state: type[StateType]
401
+ type of requested state
402
+
403
+ Returns
404
+ -------
405
+ StateType
406
+ resolved state instance
407
+ """
408
+ return StateContext.current(
409
+ state,
410
+ default=default,
411
+ )
412
+
413
+ @staticmethod
414
+ def record(
415
+ metric: State,
416
+ /,
417
+ ) -> None:
418
+ """
419
+ Record metric within current scope context.
420
+
421
+ Parameters
422
+ ----------
423
+ metric: State
424
+ value of metric to be recorded. When a metric implements __add__ it will be added to\
425
+ current value if any, otherwise subsequent calls may replace existing value.
426
+
427
+ Returns
428
+ -------
429
+ None
430
+ """
431
+
432
+ MetricsContext.record(metric)
433
+
434
+ @overload
435
+ @staticmethod
436
+ async def read[Metric: State](
437
+ metric: type[Metric],
438
+ /,
439
+ *,
440
+ merged: bool = False,
441
+ ) -> Metric | None: ...
442
+
443
+ @overload
444
+ @staticmethod
445
+ async def read[Metric: State](
446
+ metric: type[Metric],
447
+ /,
448
+ *,
449
+ merged: bool = False,
450
+ default: Metric,
451
+ ) -> Metric: ...
452
+
453
+ @staticmethod
454
+ async def read[Metric: State](
455
+ metric: type[Metric],
456
+ /,
457
+ *,
458
+ merged: bool = False,
459
+ default: Metric | None = None,
460
+ ) -> Metric | None:
461
+ """
462
+ Read metric within current scope context.
463
+
464
+ Parameters
465
+ ----------
466
+ metric: type[Metric]
467
+ type of metric to be read from current context.
468
+
469
+ merged: bool
470
+ control wheather to merge metrics from nested scopes (True)\
471
+ or access only the current scope value (False) without combining them
472
+
473
+ default: Metric | None
474
+ default value to return when metric was not recorded yet.
475
+
476
+ Returns
477
+ -------
478
+ Metric | None
479
+ """
480
+
481
+ value: Metric | None = await MetricsContext.read(
482
+ metric,
483
+ merged=merged,
484
+ )
485
+ if value is None:
486
+ return default
487
+
488
+ return value
489
+
490
+ @staticmethod
491
+ def log_error(
492
+ message: str,
493
+ /,
494
+ *args: Any,
495
+ exception: BaseException | None = None,
496
+ ) -> None:
497
+ """
498
+ Log using ERROR level within current scope context. When there is no current scope\
499
+ root logger will be used without additional details.
500
+
501
+ Parameters
502
+ ----------
503
+ message: str
504
+ message to be written to log
505
+
506
+ *args: Any
507
+ message format arguments
508
+
509
+ exception: BaseException | None = None
510
+ exception associated with log, when provided full stack trace will be recorded
511
+
512
+ Returns
513
+ -------
514
+ None
515
+ """
516
+
517
+ LoggerContext.log_error(
518
+ message,
519
+ *args,
520
+ exception=exception,
521
+ )
522
+
523
+ @staticmethod
524
+ def log_warning(
525
+ message: str,
526
+ /,
527
+ *args: Any,
528
+ exception: Exception | None = None,
529
+ ) -> None:
530
+ """
531
+ Log using WARNING level within current scope context. When there is no current scope\
532
+ root logger will be used without additional details.
533
+
534
+ Parameters
535
+ ----------
536
+ message: str
537
+ message to be written to log
538
+
539
+ *args: Any
540
+ message format arguments
541
+
542
+ exception: BaseException | None = None
543
+ exception associated with log, when provided full stack trace will be recorded
544
+
545
+ Returns
546
+ -------
547
+ None
548
+ """
549
+
550
+ LoggerContext.log_warning(
551
+ message,
552
+ *args,
553
+ exception=exception,
554
+ )
555
+
556
+ @staticmethod
557
+ def log_info(
558
+ message: str,
559
+ /,
560
+ *args: Any,
561
+ ) -> None:
562
+ """
563
+ Log using INFO level within current scope context. When there is no current scope\
564
+ root logger will be used without additional details.
565
+
566
+ Parameters
567
+ ----------
568
+ message: str
569
+ message to be written to log
570
+
571
+ *args: Any
572
+ message format arguments
573
+
574
+ Returns
575
+ -------
576
+ None
577
+ """
578
+
579
+ LoggerContext.log_info(
580
+ message,
581
+ *args,
582
+ )
583
+
584
+ @staticmethod
585
+ def log_debug(
586
+ message: str,
587
+ /,
588
+ *args: Any,
589
+ exception: Exception | None = None,
590
+ ) -> None:
591
+ """
592
+ Log using DEBUG level within current scope context. When there is no current scope\
593
+ root logger will be used without additional details.
594
+
595
+ Parameters
596
+ ----------
597
+ message: str
598
+ message to be written to log
599
+
600
+ *args: Any
601
+ message format arguments
602
+
603
+ exception: BaseException | None = None
604
+ exception associated with log, when provided full stack trace will be recorded
605
+
606
+ Returns
607
+ -------
608
+ None
609
+ """
610
+
611
+ LoggerContext.log_debug(
612
+ message,
613
+ *args,
614
+ exception=exception,
615
+ )
@@ -0,0 +1,78 @@
1
+ from asyncio import gather
2
+ from collections.abc import Iterable
3
+ from contextlib import AbstractAsyncContextManager
4
+ from itertools import chain
5
+ from types import TracebackType
6
+ from typing import final
7
+
8
+ from haiway.state import State
9
+ from haiway.utils import freeze
10
+
11
+ __all__ = [
12
+ "Disposable",
13
+ "Disposables",
14
+ ]
15
+
16
+ type Disposable = AbstractAsyncContextManager[Iterable[State] | State | None]
17
+
18
+
19
+ @final
20
+ class Disposables:
21
+ def __init__(
22
+ self,
23
+ *disposables: Disposable,
24
+ ) -> None:
25
+ self._disposables: tuple[Disposable, ...] = disposables
26
+
27
+ freeze(self)
28
+
29
+ def __bool__(self) -> bool:
30
+ return len(self._disposables) > 0
31
+
32
+ async def _initialize(
33
+ self,
34
+ disposable: Disposable,
35
+ /,
36
+ ) -> Iterable[State]:
37
+ match await disposable.__aenter__():
38
+ case None:
39
+ return ()
40
+
41
+ case State() as single:
42
+ return (single,)
43
+
44
+ case multiple:
45
+ return multiple
46
+
47
+ async def __aenter__(self) -> Iterable[State]:
48
+ return [
49
+ *chain.from_iterable(
50
+ state
51
+ for state in await gather(
52
+ *[self._initialize(disposable) for disposable in self._disposables],
53
+ )
54
+ )
55
+ ]
56
+
57
+ async def __aexit__(
58
+ self,
59
+ exc_type: type[BaseException] | None,
60
+ exc_val: BaseException | None,
61
+ exc_tb: TracebackType | None,
62
+ ) -> None:
63
+ results: list[bool | BaseException | None] = await gather(
64
+ *[
65
+ disposable.__aexit__(
66
+ exc_type,
67
+ exc_val,
68
+ exc_tb,
69
+ )
70
+ for disposable in self._disposables
71
+ ],
72
+ return_exceptions=True,
73
+ )
74
+
75
+ exceptions: list[BaseException] = [exc for exc in results if isinstance(exc, BaseException)]
76
+
77
+ if len(exceptions) > 1:
78
+ raise BaseExceptionGroup("Disposing errors", exceptions)