haiway 0.13.0__py3-none-any.whl → 0.14.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/__init__.py CHANGED
@@ -40,6 +40,7 @@ from haiway.types import (
40
40
  )
41
41
  from haiway.utils import (
42
42
  AsyncQueue,
43
+ AsyncStream,
43
44
  always,
44
45
  as_dict,
45
46
  as_list,
@@ -63,6 +64,7 @@ __all__ = [
63
64
  "MISSING",
64
65
  "ArgumentsTrace",
65
66
  "AsyncQueue",
67
+ "AsyncStream",
66
68
  "AttributePath",
67
69
  "AttributeRequirement",
68
70
  "Default",
haiway/context/access.py CHANGED
@@ -11,7 +11,6 @@ from collections.abc import (
11
11
  Coroutine,
12
12
  Iterable,
13
13
  )
14
- from contextvars import Context, copy_context
15
14
  from logging import Logger
16
15
  from types import TracebackType
17
16
  from typing import Any, final, overload
@@ -24,6 +23,7 @@ from haiway.context.state import StateContext
24
23
  from haiway.context.tasks import TaskGroupContext
25
24
  from haiway.state import State
26
25
  from haiway.utils import mimic_function
26
+ from haiway.utils.stream import AsyncStream
27
27
 
28
28
  __all__ = [
29
29
  "ctx",
@@ -37,7 +37,6 @@ class ScopeContext:
37
37
  "_identifier",
38
38
  "_logger_context",
39
39
  "_metrics_context",
40
- "_state",
41
40
  "_state_context",
42
41
  "_task_group_context",
43
42
  )
@@ -67,13 +66,12 @@ class ScopeContext:
67
66
  )
68
67
  # postponing task group creation to include only when needed
69
68
  self._task_group_context: TaskGroupContext
70
- # postponing state creation to include disposables state when prepared
69
+ # prepare state context to capture current state
71
70
  self._state_context: StateContext
72
- self._state: tuple[State, ...]
73
71
  object.__setattr__(
74
72
  self,
75
- "_state",
76
- state,
73
+ "_state_context",
74
+ StateContext.updated(state),
77
75
  )
78
76
  self._disposables: Disposables | None
79
77
  object.__setattr__(
@@ -115,12 +113,6 @@ class ScopeContext:
115
113
  assert self._disposables is None, "Can't enter synchronous context with disposables" # nosec: B101
116
114
  self._identifier.__enter__()
117
115
  self._logger_context.__enter__()
118
- # lazily initialize state
119
- object.__setattr__(
120
- self,
121
- "_state_context",
122
- StateContext.updated(self._state),
123
- )
124
116
  self._state_context.__enter__()
125
117
  self._metrics_context.__enter__()
126
118
 
@@ -169,24 +161,17 @@ class ScopeContext:
169
161
 
170
162
  # lazily initialize state to include disposables results
171
163
  if self._disposables is not None:
164
+ assert self._state_context._token is None # nosec: B101
172
165
  object.__setattr__(
173
166
  self,
174
167
  "_state_context",
175
- StateContext.updated(
176
- (
177
- *self._state,
178
- *await self._disposables.__aenter__(),
179
- )
168
+ StateContext(
169
+ state=self._state_context._state.updated(
170
+ await self._disposables.__aenter__(),
171
+ ),
180
172
  ),
181
173
  )
182
174
 
183
- else:
184
- object.__setattr__(
185
- self,
186
- "_state_context",
187
- StateContext.updated(self._state),
188
- )
189
-
190
175
  self._state_context.__enter__()
191
176
  self._metrics_context.__enter__()
192
177
 
@@ -238,8 +223,8 @@ class ScopeContext:
238
223
  @overload
239
224
  def __call__[Result, **Arguments](
240
225
  self,
241
- function: Callable[Arguments, Coroutine[None, None, Result]],
242
- ) -> Callable[Arguments, Coroutine[None, None, Result]]: ...
226
+ function: Callable[Arguments, Coroutine[Any, Any, Result]],
227
+ ) -> Callable[Arguments, Coroutine[Any, Any, Result]]: ...
243
228
 
244
229
  @overload
245
230
  def __call__[Result, **Arguments](
@@ -249,8 +234,8 @@ class ScopeContext:
249
234
 
250
235
  def __call__[Result, **Arguments](
251
236
  self,
252
- function: Callable[Arguments, Coroutine[None, None, Result]] | Callable[Arguments, Result],
253
- ) -> Callable[Arguments, Coroutine[None, None, Result]] | Callable[Arguments, Result]:
237
+ function: Callable[Arguments, Coroutine[Any, Any, Result]] | Callable[Arguments, Result],
238
+ ) -> Callable[Arguments, Coroutine[Any, Any, Result]] | Callable[Arguments, Result]:
254
239
  if iscoroutinefunction(function):
255
240
 
256
241
  async def async_context(
@@ -372,7 +357,7 @@ class ctx:
372
357
 
373
358
  @staticmethod
374
359
  def spawn[Result, **Arguments](
375
- function: Callable[Arguments, Coroutine[None, None, Result]],
360
+ function: Callable[Arguments, Coroutine[Any, Any, Result]],
376
361
  /,
377
362
  *args: Arguments.args,
378
363
  **kwargs: Arguments.kwargs,
@@ -383,7 +368,7 @@ class ctx:
383
368
 
384
369
  Parameters
385
370
  ----------
386
- function: Callable[Arguments, Coroutine[None, None, Result]]
371
+ function: Callable[Arguments, Coroutine[Any, Any, Result]]
387
372
  function to be called within the task group
388
373
 
389
374
  *args: Arguments.args
@@ -401,12 +386,12 @@ class ctx:
401
386
  return TaskGroupContext.run(function, *args, **kwargs)
402
387
 
403
388
  @staticmethod
404
- def stream[Result, **Arguments](
405
- source: Callable[Arguments, AsyncGenerator[Result, None]],
389
+ def stream[Element, **Arguments](
390
+ source: Callable[Arguments, AsyncGenerator[Element, None]],
406
391
  /,
407
392
  *args: Arguments.args,
408
393
  **kwargs: Arguments.kwargs,
409
- ) -> AsyncIterator[Result]:
394
+ ) -> AsyncIterator[Element]:
410
395
  """
411
396
  Stream results produced by a generator within the proper context state.
412
397
 
@@ -427,25 +412,22 @@ class ctx:
427
412
  iterator for accessing generated results
428
413
  """
429
414
 
430
- # prepare context snapshot
431
- context_snapshot: Context = copy_context()
432
-
433
- # prepare nested context
434
- streaming_context: ScopeContext = ctx.scope(
435
- getattr(
436
- source,
437
- "__name__",
438
- "streaming",
439
- )
440
- )
415
+ output_stream = AsyncStream[Element]()
441
416
 
442
- async def generator() -> AsyncGenerator[Result, None]:
443
- async with streaming_context:
417
+ @ctx.scope("stream")
418
+ async def stream() -> None:
419
+ try:
444
420
  async for result in source(*args, **kwargs):
445
- yield result
421
+ await output_stream.send(result)
422
+
423
+ except BaseException as exc:
424
+ output_stream.finish(exception=exc)
425
+
426
+ else:
427
+ output_stream.finish()
446
428
 
447
- # finally return it as an iterator
448
- return context_snapshot.run(generator)
429
+ TaskGroupContext.run(stream)
430
+ return output_stream
449
431
 
450
432
  @staticmethod
451
433
  def check_cancellation() -> None:
@@ -488,7 +470,7 @@ class ctx:
488
470
  StateType
489
471
  resolved state instance
490
472
  """
491
- return StateContext.current(
473
+ return StateContext.state(
492
474
  state,
493
475
  default=default,
494
476
  )
haiway/context/state.py CHANGED
@@ -92,7 +92,7 @@ class StateContext:
92
92
  _context = ContextVar[ScopeState]("StateContext")
93
93
 
94
94
  @classmethod
95
- def current[StateType: State](
95
+ def state[StateType: State](
96
96
  cls,
97
97
  state: type[StateType],
98
98
  /,
haiway/context/tasks.py CHANGED
@@ -16,7 +16,7 @@ class TaskGroupContext:
16
16
  @classmethod
17
17
  def run[Result, **Arguments](
18
18
  cls,
19
- function: Callable[Arguments, Coroutine[None, None, Result]],
19
+ function: Callable[Arguments, Coroutine[Any, Any, Result]],
20
20
  /,
21
21
  *args: Arguments.args,
22
22
  **kwargs: Arguments.kwargs,
@@ -1,5 +1,5 @@
1
1
  from haiway.helpers.asynchrony import asynchronous, wrap_async
2
- from haiway.helpers.caching import cache
2
+ from haiway.helpers.caching import CacheMakeKey, CacheRead, CacheWrite, cache
3
3
  from haiway.helpers.metrics import MetricsHolder, MetricsLogger
4
4
  from haiway.helpers.retries import retry
5
5
  from haiway.helpers.throttling import throttle
@@ -8,6 +8,9 @@ from haiway.helpers.tracing import ArgumentsTrace, ResultTrace, traced
8
8
 
9
9
  __all__ = [
10
10
  "ArgumentsTrace",
11
+ "CacheMakeKey",
12
+ "CacheRead",
13
+ "CacheWrite",
11
14
  "MetricsHolder",
12
15
  "MetricsLogger",
13
16
  "ResultTrace",
@@ -14,9 +14,9 @@ __all__ = [
14
14
 
15
15
 
16
16
  def wrap_async[**Args, Result](
17
- function: Callable[Args, Coroutine[None, None, Result]] | Callable[Args, Result],
17
+ function: Callable[Args, Coroutine[Any, Any, Result]] | Callable[Args, Result],
18
18
  /,
19
- ) -> Callable[Args, Coroutine[None, None, Result]]:
19
+ ) -> Callable[Args, Coroutine[Any, Any, Result]]:
20
20
  if iscoroutinefunction(function):
21
21
  return function
22
22
 
@@ -30,10 +30,12 @@ def wrap_async[**Args, Result](
30
30
 
31
31
 
32
32
  @overload
33
- def asynchronous[**Args, Result]() -> Callable[
34
- [Callable[Args, Result]],
35
- Callable[Args, Coroutine[None, None, Result]],
36
- ]: ...
33
+ def asynchronous[**Args, Result]() -> (
34
+ Callable[
35
+ [Callable[Args, Result]],
36
+ Callable[Args, Coroutine[Any, Any, Result]],
37
+ ]
38
+ ): ...
37
39
 
38
40
 
39
41
  @overload
@@ -43,7 +45,7 @@ def asynchronous[**Args, Result](
43
45
  executor: Executor,
44
46
  ) -> Callable[
45
47
  [Callable[Args, Result]],
46
- Callable[Args, Coroutine[None, None, Result]],
48
+ Callable[Args, Coroutine[Any, Any, Result]],
47
49
  ]: ...
48
50
 
49
51
 
@@ -51,7 +53,7 @@ def asynchronous[**Args, Result](
51
53
  def asynchronous[**Args, Result](
52
54
  function: Callable[Args, Result],
53
55
  /,
54
- ) -> Callable[Args, Coroutine[None, None, Result]]: ...
56
+ ) -> Callable[Args, Coroutine[Any, Any, Result]]: ...
55
57
 
56
58
 
57
59
  def asynchronous[**Args, Result](
@@ -62,9 +64,9 @@ def asynchronous[**Args, Result](
62
64
  ) -> (
63
65
  Callable[
64
66
  [Callable[Args, Result]],
65
- Callable[Args, Coroutine[None, None, Result]],
67
+ Callable[Args, Coroutine[Any, Any, Result]],
66
68
  ]
67
- | Callable[Args, Coroutine[None, None, Result]]
69
+ | Callable[Args, Coroutine[Any, Any, Result]]
68
70
  ):
69
71
  """\
70
72
  Wrapper for a sync function to convert it to an async function. \
@@ -90,7 +92,7 @@ def asynchronous[**Args, Result](
90
92
 
91
93
  def wrap(
92
94
  wrapped: Callable[Args, Result],
93
- ) -> Callable[Args, Coroutine[None, None, Result]]:
95
+ ) -> Callable[Args, Coroutine[Any, Any, Result]]:
94
96
  assert not iscoroutinefunction(wrapped), "Cannot wrap async function in executor" # nosec: B101
95
97
 
96
98
  return _ExecutorWrapper(
@@ -152,7 +154,7 @@ class _ExecutorWrapper[**Args, Result]:
152
154
  instance: object,
153
155
  owner: type | None = None,
154
156
  /,
155
- ) -> Callable[Args, Coroutine[None, None, Result]]:
157
+ ) -> Callable[Args, Coroutine[Any, Any, Result]]:
156
158
  if owner is None:
157
159
  return self
158
160
 
@@ -180,8 +182,8 @@ class _ExecutorWrapper[**Args, Result]:
180
182
  def _mimic_async[**Args, Result](
181
183
  function: Callable[Args, Result],
182
184
  /,
183
- within: Callable[..., Coroutine[None, None, Result]],
184
- ) -> Callable[Args, Coroutine[None, None, Result]]:
185
+ within: Callable[..., Coroutine[Any, Any, Result]],
186
+ ) -> Callable[Args, Coroutine[Any, Any, Result]]:
185
187
  try:
186
188
  annotations: Any = getattr( # noqa: B009
187
189
  function,
@@ -192,7 +194,7 @@ def _mimic_async[**Args, Result](
192
194
  "__annotations__",
193
195
  {
194
196
  **annotations,
195
- "return": Coroutine[None, None, annotations.get("return", Any)],
197
+ "return": Coroutine[Any, Any, annotations.get("return", Any)],
196
198
  },
197
199
  )
198
200
 
@@ -234,6 +236,6 @@ def _mimic_async[**Args, Result](
234
236
  )
235
237
 
236
238
  return cast(
237
- Callable[Args, Coroutine[None, None, Result]],
239
+ Callable[Args, Coroutine[Any, Any, Result]],
238
240
  within,
239
241
  )
haiway/helpers/caching.py CHANGED
@@ -3,12 +3,15 @@ from collections import OrderedDict
3
3
  from collections.abc import Callable, Coroutine, Hashable
4
4
  from functools import _make_key # pyright: ignore[reportPrivateUsage]
5
5
  from time import monotonic
6
- from typing import NamedTuple, Protocol, cast, overload
6
+ from typing import Any, NamedTuple, Protocol, cast, overload
7
7
 
8
8
  from haiway.context.access import ctx
9
9
  from haiway.utils.mimic import mimic_function
10
10
 
11
11
  __all__ = [
12
+ "CacheMakeKey",
13
+ "CacheRead",
14
+ "CacheWrite",
12
15
  "cache",
13
16
  ]
14
17
 
@@ -59,7 +62,7 @@ def cache[**Args, Result, Key](
59
62
  read: CacheRead[Key, Result],
60
63
  write: CacheWrite[Key, Result],
61
64
  ) -> Callable[
62
- [Callable[Args, Coroutine[None, None, Result]]], Callable[Args, Coroutine[None, None, Result]]
65
+ [Callable[Args, Coroutine[Any, Any, Result]]], Callable[Args, Coroutine[Any, Any, Result]]
63
66
  ]: ...
64
67
 
65
68
 
@@ -73,8 +76,8 @@ def cache[**Args, Result, Key]( # noqa: PLR0913
73
76
  write: CacheWrite[Key, Result] | None = None,
74
77
  ) -> (
75
78
  Callable[
76
- [Callable[Args, Coroutine[None, None, Result]]],
77
- Callable[Args, Coroutine[None, None, Result]],
79
+ [Callable[Args, Coroutine[Any, Any, Result]]],
80
+ Callable[Args, Coroutine[Any, Any, Result]],
78
81
  ]
79
82
  | Callable[[Callable[Args, Result]], Callable[Args, Result]]
80
83
  | Callable[Args, Result]
@@ -317,13 +320,13 @@ class _AsyncCache[**Args, Result]:
317
320
 
318
321
  def __init__(
319
322
  self,
320
- function: Callable[Args, Coroutine[None, None, Result]],
323
+ function: Callable[Args, Coroutine[Any, Any, Result]],
321
324
  /,
322
325
  limit: int,
323
326
  expiration: float | None,
324
327
  make_key: CacheMakeKey[Args, Hashable],
325
328
  ) -> None:
326
- self._function: Callable[Args, Coroutine[None, None, Result]] = function
329
+ self._function: Callable[Args, Coroutine[Any, Any, Result]] = function
327
330
  self._cached: OrderedDict[Hashable, _CacheEntry[Result]] = OrderedDict()
328
331
  self._limit: int = limit
329
332
  self._make_key: CacheMakeKey[Args, Hashable] = make_key
@@ -348,7 +351,7 @@ class _AsyncCache[**Args, Result]:
348
351
  instance: object | None,
349
352
  owner: type | None = None,
350
353
  /,
351
- ) -> Callable[Args, Coroutine[None, None, Result]]:
354
+ ) -> Callable[Args, Coroutine[Any, Any, Result]]:
352
355
  assert owner is None and instance is None, "cache does not work for classes" # nosec: B101
353
356
  return self
354
357
 
@@ -405,13 +408,13 @@ class _CustomCache[**Args, Result, Key]:
405
408
 
406
409
  def __init__(
407
410
  self,
408
- function: Callable[Args, Coroutine[None, None, Result]],
411
+ function: Callable[Args, Coroutine[Any, Any, Result]],
409
412
  /,
410
413
  make_key: CacheMakeKey[Args, Key],
411
414
  read: CacheRead[Key, Result],
412
415
  write: CacheWrite[Key, Result],
413
416
  ) -> None:
414
- self._function: Callable[Args, Coroutine[None, None, Result]] = function
417
+ self._function: Callable[Args, Coroutine[Any, Any, Result]] = function
415
418
  self._make_key: CacheMakeKey[Args, Key] = make_key
416
419
  self._read: CacheRead[Key, Result] = read
417
420
  self._write: CacheWrite[Key, Result] = write
haiway/helpers/metrics.py CHANGED
@@ -15,6 +15,7 @@ __all_ = [
15
15
 
16
16
  class MetricsScopeStore:
17
17
  __slots__ = (
18
+ "allow_exit",
18
19
  "entered",
19
20
  "exited",
20
21
  "identifier",
@@ -31,6 +32,7 @@ class MetricsScopeStore:
31
32
  self.entered: float = monotonic()
32
33
  self.metrics: dict[type[State], State] = {}
33
34
  self.exited: float | None = None
35
+ self.allow_exit: bool = False
34
36
  self.nested: list[MetricsScopeStore] = []
35
37
 
36
38
  @property
@@ -115,7 +117,7 @@ class MetricsHolder:
115
117
 
116
118
  def __init__(self) -> None:
117
119
  self.root_scope: ScopeIdentifier | None = None
118
- self.scopes: dict[ScopeIdentifier, MetricsScopeStore] = {}
120
+ self.scopes: dict[str, MetricsScopeStore] = {}
119
121
 
120
122
  def record(
121
123
  self,
@@ -124,10 +126,10 @@ class MetricsHolder:
124
126
  metric: State,
125
127
  ) -> None:
126
128
  assert self.root_scope is not None # nosec: B101
127
- assert scope in self.scopes # nosec: B101
129
+ assert scope.scope_id in self.scopes # nosec: B101
128
130
 
129
131
  metric_type: type[State] = type(metric)
130
- metrics: dict[type[State], State] = self.scopes[scope].metrics
132
+ metrics: dict[type[State], State] = self.scopes[scope.scope_id].metrics
131
133
  if (current := metrics.get(metric_type)) and hasattr(current, "__add__"):
132
134
  metrics[type(metric)] = current.__add__(metric) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
133
135
 
@@ -142,29 +144,29 @@ class MetricsHolder:
142
144
  merged: bool,
143
145
  ) -> Metric | None:
144
146
  assert self.root_scope is not None # nosec: B101
145
- assert scope in self.scopes # nosec: B101
147
+ assert scope.scope_id in self.scopes # nosec: B101
146
148
 
147
149
  if merged:
148
- return self.scopes[scope].merged(metric)
150
+ return self.scopes[scope.scope_id].merged(metric)
149
151
 
150
152
  else:
151
- return cast(Metric | None, self.scopes[scope].metrics.get(metric))
153
+ return cast(Metric | None, self.scopes[scope.scope_id].metrics.get(metric))
152
154
 
153
155
  def enter_scope[Metric: State](
154
156
  self,
155
157
  scope: ScopeIdentifier,
156
158
  /,
157
159
  ) -> None:
158
- assert scope not in self.scopes # nosec: B101
160
+ assert scope.scope_id not in self.scopes # nosec: B101
159
161
  scope_metrics = MetricsScopeStore(scope)
160
- self.scopes[scope] = scope_metrics
162
+ self.scopes[scope.scope_id] = scope_metrics
161
163
 
162
164
  if self.root_scope is None:
163
165
  self.root_scope = scope
164
166
 
165
167
  else:
166
168
  for key in self.scopes.keys():
167
- if key.scope_id == scope.parent_id:
169
+ if key == scope.parent_id:
168
170
  self.scopes[key].nested.append(scope_metrics)
169
171
  return
170
172
 
@@ -177,8 +179,18 @@ class MetricsHolder:
177
179
  scope: ScopeIdentifier,
178
180
  /,
179
181
  ) -> None:
180
- assert scope in self.scopes # nosec: B101
181
- self.scopes[scope].exited = monotonic()
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)
182
194
 
183
195
 
184
196
  @final
@@ -213,7 +225,7 @@ class MetricsLogger:
213
225
  redact_content: bool,
214
226
  ) -> None:
215
227
  self.root_scope: ScopeIdentifier | None = None
216
- self.scopes: dict[ScopeIdentifier, MetricsScopeStore] = {}
228
+ self.scopes: dict[str, MetricsScopeStore] = {}
217
229
  self.items_limit: int | None = items_limit
218
230
  self.redact_content: bool = redact_content
219
231
 
@@ -224,10 +236,10 @@ class MetricsLogger:
224
236
  metric: State,
225
237
  ) -> None:
226
238
  assert self.root_scope is not None # nosec: B101
227
- assert scope in self.scopes # nosec: B101
239
+ assert scope.scope_id in self.scopes # nosec: B101
228
240
 
229
241
  metric_type: type[State] = type(metric)
230
- metrics: dict[type[State], State] = self.scopes[scope].metrics
242
+ metrics: dict[type[State], State] = self.scopes[scope.scope_id].metrics
231
243
  if (current := metrics.get(metric_type)) and hasattr(current, "__add__"):
232
244
  metrics[type(metric)] = current.__add__(metric) # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
233
245
 
@@ -248,29 +260,29 @@ class MetricsLogger:
248
260
  merged: bool,
249
261
  ) -> Metric | None:
250
262
  assert self.root_scope is not None # nosec: B101
251
- assert scope in self.scopes # nosec: B101
263
+ assert scope.scope_id in self.scopes # nosec: B101
252
264
 
253
265
  if merged:
254
- return self.scopes[scope].merged(metric)
266
+ return self.scopes[scope.scope_id].merged(metric)
255
267
 
256
268
  else:
257
- return cast(Metric | None, self.scopes[scope].metrics.get(metric))
269
+ return cast(Metric | None, self.scopes[scope.scope_id].metrics.get(metric))
258
270
 
259
271
  def enter_scope[Metric: State](
260
272
  self,
261
273
  scope: ScopeIdentifier,
262
274
  /,
263
275
  ) -> None:
264
- assert scope not in self.scopes # nosec: B101
276
+ assert scope.scope_id not in self.scopes # nosec: B101
265
277
  scope_metrics = MetricsScopeStore(scope)
266
- self.scopes[scope] = scope_metrics
278
+ self.scopes[scope.scope_id] = scope_metrics
267
279
 
268
280
  if self.root_scope is None:
269
281
  self.root_scope = scope
270
282
 
271
283
  else:
272
284
  for key in self.scopes.keys():
273
- if key.scope_id == scope.parent_id:
285
+ if key == scope.parent_id:
274
286
  self.scopes[key].nested.append(scope_metrics)
275
287
  return
276
288
 
@@ -283,12 +295,22 @@ class MetricsLogger:
283
295
  scope: ScopeIdentifier,
284
296
  /,
285
297
  ) -> None:
286
- assert scope in self.scopes # nosec: B101
287
- self.scopes[scope].exited = monotonic()
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)
288
310
 
289
- if scope == self.root_scope and self.scopes[scope].finished:
311
+ elif scope == self.root_scope and self.scopes[self.root_scope.scope_id].finished:
290
312
  if log := _tree_log(
291
- self.scopes[scope],
313
+ self.scopes[scope.scope_id],
292
314
  list_items_limit=self.items_limit,
293
315
  redact_content=self.redact_content,
294
316
  ):
haiway/helpers/retries.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from asyncio import CancelledError, iscoroutinefunction, sleep
2
2
  from collections.abc import Callable, Coroutine
3
3
  from time import sleep as sleep_sync
4
- from typing import cast, overload
4
+ from typing import Any, cast, overload
5
5
 
6
6
  from haiway.context import ctx
7
7
  from haiway.utils import mimic_function
@@ -178,12 +178,12 @@ def _wrap_sync[**Args, Result](
178
178
 
179
179
 
180
180
  def _wrap_async[**Args, Result](
181
- function: Callable[Args, Coroutine[None, None, Result]],
181
+ function: Callable[Args, Coroutine[Any, Any, Result]],
182
182
  *,
183
183
  limit: int,
184
184
  delay: Callable[[int, Exception], float] | float | None,
185
185
  catching: set[type[Exception]] | tuple[type[Exception], ...],
186
- ) -> Callable[Args, Coroutine[None, None, Result]]:
186
+ ) -> Callable[Args, Coroutine[Any, Any, Result]]:
187
187
  assert limit > 0, "Limit has to be greater than zero" # nosec: B101
188
188
 
189
189
  @mimic_function(function)
@@ -7,7 +7,7 @@ from collections import deque
7
7
  from collections.abc import Callable, Coroutine
8
8
  from datetime import timedelta
9
9
  from time import monotonic
10
- from typing import cast, overload
10
+ from typing import Any, cast, overload
11
11
 
12
12
  from haiway.utils.mimic import mimic_function
13
13
 
@@ -18,9 +18,9 @@ __all__ = [
18
18
 
19
19
  @overload
20
20
  def throttle[**Args, Result](
21
- function: Callable[Args, Coroutine[None, None, Result]],
21
+ function: Callable[Args, Coroutine[Any, Any, Result]],
22
22
  /,
23
- ) -> Callable[Args, Coroutine[None, None, Result]]: ...
23
+ ) -> Callable[Args, Coroutine[Any, Any, Result]]: ...
24
24
 
25
25
 
26
26
  @overload
@@ -29,21 +29,21 @@ def throttle[**Args, Result](
29
29
  limit: int = 1,
30
30
  period: timedelta | float = 1,
31
31
  ) -> Callable[
32
- [Callable[Args, Coroutine[None, None, Result]]], Callable[Args, Coroutine[None, None, Result]]
32
+ [Callable[Args, Coroutine[Any, Any, Result]]], Callable[Args, Coroutine[Any, Any, Result]]
33
33
  ]: ...
34
34
 
35
35
 
36
36
  def throttle[**Args, Result](
37
- function: Callable[Args, Coroutine[None, None, Result]] | None = None,
37
+ function: Callable[Args, Coroutine[Any, Any, Result]] | None = None,
38
38
  *,
39
39
  limit: int = 1,
40
40
  period: timedelta | float = 1,
41
41
  ) -> (
42
42
  Callable[
43
- [Callable[Args, Coroutine[None, None, Result]]],
44
- Callable[Args, Coroutine[None, None, Result]],
43
+ [Callable[Args, Coroutine[Any, Any, Result]]],
44
+ Callable[Args, Coroutine[Any, Any, Result]],
45
45
  ]
46
- | Callable[Args, Coroutine[None, None, Result]]
46
+ | Callable[Args, Coroutine[Any, Any, Result]]
47
47
  ):
48
48
  """\
49
49
  Throttle for function calls with custom limit and period time. \
@@ -53,7 +53,7 @@ def throttle[**Args, Result](
53
53
 
54
54
  Parameters
55
55
  ----------
56
- function: Callable[Args, Coroutine[None, None, Result]]
56
+ function: Callable[Args, Coroutine[Any, Any, Result]]
57
57
  function to wrap in throttle
58
58
  limit: int
59
59
  limit of executions in given period, if no period was specified
@@ -63,17 +63,17 @@ def throttle[**Args, Result](
63
63
 
64
64
  Returns
65
65
  -------
66
- Callable[[Callable[Args, Coroutine[None, None, Result]]], Callable[Args, Coroutine[None, None, Result]]] \
67
- | Callable[Args, Coroutine[None, None, Result]]
66
+ Callable[[Callable[Args, Coroutine[Any, Any, Result]]], Callable[Args, Coroutine[Any, Any, Result]]] \
67
+ | Callable[Args, Coroutine[Any, Any, Result]]
68
68
  provided function wrapped in throttle
69
69
  """ # noqa: E501
70
70
 
71
71
  def _wrap(
72
- function: Callable[Args, Coroutine[None, None, Result]],
73
- ) -> Callable[Args, Coroutine[None, None, Result]]:
72
+ function: Callable[Args, Coroutine[Any, Any, Result]],
73
+ ) -> Callable[Args, Coroutine[Any, Any, Result]]:
74
74
  assert iscoroutinefunction(function) # nosec: B101
75
75
  return cast(
76
- Callable[Args, Coroutine[None, None, Result]],
76
+ Callable[Args, Coroutine[Any, Any, Result]],
77
77
  _AsyncThrottle(
78
78
  function,
79
79
  limit=limit,
@@ -107,12 +107,12 @@ class _AsyncThrottle[**Args, Result]:
107
107
 
108
108
  def __init__(
109
109
  self,
110
- function: Callable[Args, Coroutine[None, None, Result]],
110
+ function: Callable[Args, Coroutine[Any, Any, Result]],
111
111
  /,
112
112
  limit: int,
113
113
  period: timedelta | float,
114
114
  ) -> None:
115
- self._function: Callable[Args, Coroutine[None, None, Result]] = function
115
+ self._function: Callable[Args, Coroutine[Any, Any, Result]] = function
116
116
  self._entries: deque[float] = deque()
117
117
  self._lock: Lock = Lock()
118
118
  self._limit: int = limit
@@ -1,5 +1,6 @@
1
1
  from asyncio import AbstractEventLoop, Future, Task, TimerHandle, get_running_loop
2
2
  from collections.abc import Callable, Coroutine
3
+ from typing import Any
3
4
 
4
5
  from haiway.utils.mimic import mimic_function
5
6
 
@@ -12,8 +13,8 @@ def timeout[**Args, Result](
12
13
  timeout: float,
13
14
  /,
14
15
  ) -> Callable[
15
- [Callable[Args, Coroutine[None, None, Result]]],
16
- Callable[Args, Coroutine[None, None, Result]],
16
+ [Callable[Args, Coroutine[Any, Any, Result]]],
17
+ Callable[Args, Coroutine[Any, Any, Result]],
17
18
  ]:
18
19
  """\
19
20
  Timeout wrapper for a function call. \
@@ -34,8 +35,8 @@ def timeout[**Args, Result](
34
35
  """
35
36
 
36
37
  def _wrap(
37
- function: Callable[Args, Coroutine[None, None, Result]],
38
- ) -> Callable[Args, Coroutine[None, None, Result]]:
38
+ function: Callable[Args, Coroutine[Any, Any, Result]],
39
+ ) -> Callable[Args, Coroutine[Any, Any, Result]]:
39
40
  return _AsyncTimeout(
40
41
  function,
41
42
  timeout=timeout,
@@ -60,11 +61,11 @@ class _AsyncTimeout[**Args, Result]:
60
61
 
61
62
  def __init__(
62
63
  self,
63
- function: Callable[Args, Coroutine[None, None, Result]],
64
+ function: Callable[Args, Coroutine[Any, Any, Result]],
64
65
  /,
65
66
  timeout: float,
66
67
  ) -> None:
67
- self._function: Callable[Args, Coroutine[None, None, Result]] = function
68
+ self._function: Callable[Args, Coroutine[Any, Any, Result]] = function
68
69
  self._timeout: float = timeout
69
70
 
70
71
  # mimic function attributes if able
haiway/utils/__init__.py CHANGED
@@ -13,9 +13,11 @@ from haiway.utils.logs import setup_logging
13
13
  from haiway.utils.mimic import mimic_function
14
14
  from haiway.utils.noop import async_noop, noop
15
15
  from haiway.utils.queue import AsyncQueue
16
+ from haiway.utils.stream import AsyncStream
16
17
 
17
18
  __all__ = [
18
19
  "AsyncQueue",
20
+ "AsyncStream",
19
21
  "always",
20
22
  "as_dict",
21
23
  "as_list",
haiway/utils/always.py CHANGED
@@ -37,7 +37,7 @@ def always[Value](
37
37
  def async_always[Value](
38
38
  value: Value,
39
39
  /,
40
- ) -> Callable[..., Coroutine[None, None, Value]]:
40
+ ) -> Callable[..., Coroutine[Any, Any, Value]]:
41
41
  """
42
42
  Factory method creating async functions returning always the same value.
43
43
 
@@ -48,7 +48,7 @@ def async_always[Value](
48
48
 
49
49
  Returns
50
50
  -------
51
- Callable[..., Coroutine[None, None, Value]]
51
+ Callable[..., Coroutine[Any, Any, Value]]
52
52
  async function ignoring arguments and always returning the provided value.
53
53
  """
54
54
 
haiway/utils/stream.py ADDED
@@ -0,0 +1,97 @@
1
+ from asyncio import (
2
+ AbstractEventLoop,
3
+ CancelledError,
4
+ Future,
5
+ get_running_loop,
6
+ )
7
+ from collections.abc import AsyncIterator
8
+
9
+ __all__ = [
10
+ "AsyncStream",
11
+ ]
12
+
13
+
14
+ class AsyncStream[Element](AsyncIterator[Element]):
15
+ def __init__(
16
+ self,
17
+ loop: AbstractEventLoop | None = None,
18
+ ) -> None:
19
+ self._loop: AbstractEventLoop = loop or get_running_loop()
20
+ self._ready: Future[None] = self._loop.create_future()
21
+ self._waiting: Future[Element] | None = None
22
+ self._finish_reason: BaseException | None = None
23
+
24
+ @property
25
+ def finished(self) -> bool:
26
+ return self._finish_reason is not None
27
+
28
+ async def send(
29
+ self,
30
+ element: Element,
31
+ /,
32
+ ) -> None:
33
+ if self._finish_reason is not None:
34
+ return # already finished
35
+
36
+ # wait for readiness
37
+ await self._ready
38
+ # we could finish while waiting
39
+ if self._finish_reason is not None:
40
+ return # already finished
41
+
42
+ assert self._waiting is not None and not self._waiting.done() # nosec: B101
43
+ # send the element
44
+ self._waiting.set_result(element)
45
+ # and create new readiness future afterwards
46
+ self._ready = self._loop.create_future()
47
+
48
+ def finish(
49
+ self,
50
+ exception: BaseException | None = None,
51
+ ) -> None:
52
+ if self.finished:
53
+ return # already finished, ignore
54
+
55
+ self._finish_reason = exception or StopAsyncIteration()
56
+
57
+ if not self._ready.done():
58
+ if get_running_loop() is not self._loop:
59
+ self._loop.call_soon_threadsafe(
60
+ self._ready.set_result,
61
+ None,
62
+ )
63
+
64
+ else:
65
+ self._ready.set_result(None)
66
+
67
+ if self._waiting is not None and not self._waiting.done():
68
+ if get_running_loop() is not self._loop:
69
+ self._loop.call_soon_threadsafe(
70
+ self._waiting.set_exception,
71
+ self._finish_reason,
72
+ )
73
+
74
+ else:
75
+ self._waiting.set_exception(self._finish_reason)
76
+
77
+ def cancel(self) -> None:
78
+ self.finish(exception=CancelledError())
79
+
80
+ async def __anext__(self) -> Element:
81
+ assert self._waiting is None, "AsyncStream can't be reused" # nosec: B101
82
+
83
+ if self._finish_reason:
84
+ raise self._finish_reason
85
+
86
+ try:
87
+ assert not self._ready.done() # nosec: B101
88
+ # create new waiting future
89
+ self._waiting = self._loop.create_future()
90
+ # and notify readiness
91
+ self._ready.set_result(None)
92
+ # and wait for the result
93
+ return await self._waiting
94
+
95
+ finally:
96
+ # cleanup waiting future
97
+ self._waiting = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haiway
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: Framework for dependency injection and state management within structured concurrency model.
5
5
  Project-URL: Homepage, https://miquido.com
6
6
  Project-URL: Repository, https://github.com/miquido/haiway.git
@@ -1,21 +1,21 @@
1
- haiway/__init__.py,sha256=ONC4Hk0GaPzhQ3oYmgh6Z4kJdXQiyJ8ZQcM_hCUz-IY,2045
1
+ haiway/__init__.py,sha256=RhW9HOIAVQ3srQ-v23tPghJ20dWcn_uAyt8U8Hhn868,2081
2
2
  haiway/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  haiway/context/__init__.py,sha256=feqd0eJnGQwh4B8BZXpS0fQRE-DqoFCFOHipF1jOY8A,762
4
- haiway/context/access.py,sha256=3dloiRk4v3ljIJ8LkjW0Tj4vx6G_mDvjEy0lKLU3Wug,18963
4
+ haiway/context/access.py,sha256=1Oq70pcp54bC9NNp-zMocDewVJpcZgepi99_xeqz98o,18502
5
5
  haiway/context/disposables.py,sha256=vcsh8jRaJ8Q1ob7oh5LsrSPw9f5AMTcaD_p_Gb7tXAI,2588
6
6
  haiway/context/identifier.py,sha256=lz-FuspOtsaEsfb7QPrEVWYfbcMJgd3A6BGG3kLbaV0,3914
7
7
  haiway/context/logging.py,sha256=F3dr6MLjodg3MX5WTInxn3r3JuihG-giBzumI0GGUQw,5590
8
8
  haiway/context/metrics.py,sha256=N20XQtC8au_e_3iWrsZdej78YBEIWF44fdtWcZBWono,5223
9
- haiway/context/state.py,sha256=qskYoNwN5Ad0OgnyhL-PyGzTZltwVVdE9CEqWWn4lm8,4554
10
- haiway/context/tasks.py,sha256=J1BFQJis_15SIXbFclppxL-AOIThg2KS4SX8Hg_-YRY,2828
9
+ haiway/context/state.py,sha256=7pXb5gvyPOWiRbxX-sSfO-hjaHcTUIp_uTKhjaSLeRo,4552
10
+ haiway/context/tasks.py,sha256=MKfsa-921cIpQ_BKskwokjR27suCHkHZa3O9kOE8UOg,2826
11
11
  haiway/context/types.py,sha256=VvJA7wAPZ3ISpgyThVguioYUXqhHf0XkPfRd0M1ERiQ,142
12
- haiway/helpers/__init__.py,sha256=8XRJWNhidWuBKqRZ1Hyc2xqt7DeWLcoOs2V-oexl8VY,579
13
- haiway/helpers/asynchrony.py,sha256=-yJsttRhKw0GgnzMc0FqIigS5UJl_G0YgkK12V5IzJg,6292
14
- haiway/helpers/caching.py,sha256=iy2upZnlpLWc1FjQP0EjAu8j-Vl0yHZmIZ93K6Gc-yY,13232
15
- haiway/helpers/metrics.py,sha256=lCSvat3IrkmytFdqTvsqkVqYcVOK_bByfwYAe0hJIWg,13614
16
- haiway/helpers/retries.py,sha256=gIkyUlqJLDYaxIZd3qzeqGFY9y5Gp8dgZLlZ6hs8hoc,7538
17
- haiway/helpers/throttling.py,sha256=RfQn8GGPqTuPWzA1CJvZvb1s00vryVpo-eqz5EY9vD4,4150
18
- haiway/helpers/timeouted.py,sha256=T2I2fThDuP_wDq_3QnZdEaUgAD16uRacbSF1fRnzAaE,3326
12
+ haiway/helpers/__init__.py,sha256=ZKDlL3twDqXyI1a9FDgRy3m1-Dfycvke6BJ4C3CndEk,671
13
+ haiway/helpers/asynchrony.py,sha256=YHLK5Hjc-5UWlQRypC11yHeEQyeAtHqrMoBTBfqQBvQ,6286
14
+ haiway/helpers/caching.py,sha256=EU5usTHGDzf0SO3bMW4hHB9oZlLlE7BxO_2ckbjYBw8,13274
15
+ haiway/helpers/metrics.py,sha256=VNxgPgV8pgt-51f2CANy1IVx8VMYIAxT3F849t3IeQs,14604
16
+ haiway/helpers/retries.py,sha256=3m1SsJW_YY_HPufX9LEzcd_MEyRRFNXvSExLeEti8W8,7539
17
+ haiway/helpers/throttling.py,sha256=r9HnUuo4nX36Pf-oMFHUJk-ZCDeXQ__JTDHlkSltRhA,4121
18
+ haiway/helpers/timeouted.py,sha256=DthIm4ytKhmiIKf-pcO_vrO1X-ImZh-sLNCWcLY9gfw,3337
19
19
  haiway/helpers/tracing.py,sha256=8Gpcc_DguuHAdaxM4rGP0mB-S-8E7DKt7ZGym9f6x6Q,4018
20
20
  haiway/state/__init__.py,sha256=emTuwGFn7HyjyTJ_ass69J5jQIA7_WHO4teZz_dR05Y,355
21
21
  haiway/state/attributes.py,sha256=3chvq3ENoIX688RSYiqZnOCpxbzt-kQ2Wl8Fc3vVyMo,23311
@@ -27,8 +27,8 @@ haiway/types/__init__.py,sha256=-j4uDN6ix3GBXLBqXC-k_QOJSDlO6zvNCxDej8vVzek,342
27
27
  haiway/types/default.py,sha256=h38-zFkbn_UPEiw1SdDF5rkObVmD9UJpmyhOgS1gQ9U,2208
28
28
  haiway/types/frozen.py,sha256=CZhFCXnWAKEhuWSfILxA8smfdpMd5Ku694ycfLh98R8,76
29
29
  haiway/types/missing.py,sha256=rDnyA2wxPkTbJl0L-zbo0owp7IJ04xkCIp6xD6wh8NI,1712
30
- haiway/utils/__init__.py,sha256=YBq9hYhrHFB-4d_M53A620-2KEz5SMU31GDBW6gXFnQ,804
31
- haiway/utils/always.py,sha256=2abp8Lm9rQkrfS3rm1Iqhb-IcWyVfH1BULab3KMxgOw,1234
30
+ haiway/utils/__init__.py,sha256=JYo5EVquL2BCBsHtvySPTio_x5hSVDJCfu_naWzbqKE,867
31
+ haiway/utils/always.py,sha256=u1tssiErzm0Q3ASc3CV1rLhcMQ54MjpMlC_bRJMQhK4,1230
32
32
  haiway/utils/collections.py,sha256=IzD-XSEyngKyzLTNG9sr7QjXIneoAzi3oRsDmbRHtzU,3276
33
33
  haiway/utils/env.py,sha256=-hI4CgLkzdyueuECVjm-TfR3lQjE2bDsc72w7vNC4nQ,5339
34
34
  haiway/utils/freezing.py,sha256=K34ZIMzbkpgkHKH-KF73plEbXExsajNRkRTYp9nJEf4,620
@@ -36,7 +36,8 @@ haiway/utils/logs.py,sha256=oDsc1ZdqKDjlTlctLbDcp9iX98Acr-1tdw-Pyg3DElo,1577
36
36
  haiway/utils/mimic.py,sha256=BkVjTVP2TxxC8GChPGyDV6UXVwJmiRiSWeOYZNZFHxs,1828
37
37
  haiway/utils/noop.py,sha256=qgbZlOKWY6_23Zs43OLukK2HagIQKRyR04zrFVm5rWI,344
38
38
  haiway/utils/queue.py,sha256=Tk1bXvuNbEgapeC3-h_PYBASqVjhEoL8mUvtJnM29xI,4000
39
- haiway-0.13.0.dist-info/METADATA,sha256=vlLkeTbiQjf4ilZEEqTwrj18ZzgFzl_rPSHExuTxb0A,4299
40
- haiway-0.13.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
41
- haiway-0.13.0.dist-info/licenses/LICENSE,sha256=3phcpHVNBP8jsi77gOO0E7rgKeDeu99Pi7DSnK9YHoQ,1069
42
- haiway-0.13.0.dist-info/RECORD,,
39
+ haiway/utils/stream.py,sha256=Vqyi0EwcupkVyKQ7eple6z9DkcbSHkE-6yMw85mak9Q,2832
40
+ haiway-0.14.0.dist-info/METADATA,sha256=e01xN8K8-d8RiPRaV2ibtsxEHfITllUZmxZA_VzEpBs,4299
41
+ haiway-0.14.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
42
+ haiway-0.14.0.dist-info/licenses/LICENSE,sha256=3phcpHVNBP8jsi77gOO0E7rgKeDeu99Pi7DSnK9YHoQ,1069
43
+ haiway-0.14.0.dist-info/RECORD,,