haiway 0.3.2__py3-none-any.whl → 0.4.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/access.py CHANGED
@@ -1,12 +1,16 @@
1
1
  from asyncio import (
2
+ CancelledError,
2
3
  Task,
3
4
  current_task,
4
5
  )
5
6
  from collections.abc import (
7
+ AsyncGenerator,
8
+ AsyncIterator,
6
9
  Callable,
7
10
  Coroutine,
8
11
  Iterable,
9
12
  )
13
+ from contextvars import Context, copy_context
10
14
  from logging import Logger
11
15
  from types import TracebackType
12
16
  from typing import Any, final
@@ -32,32 +36,28 @@ class ScopeContext:
32
36
  logger: Logger | None,
33
37
  state: tuple[State, ...],
34
38
  disposables: Disposables | None,
35
- task_group: TaskGroupContext,
36
- completion: Callable[[ScopeMetrics], Coroutine[None, None, None]] | None,
39
+ completion: Callable[[ScopeMetrics], Coroutine[None, None, None]]
40
+ | Callable[[ScopeMetrics], None]
41
+ | None,
37
42
  ) -> None:
38
- self._task_group: TaskGroupContext = task_group
39
- self._logger: Logger | None = logger
40
- self._trace_id: str | None = trace_id
41
- self._name: str = name
43
+ self._task_group_context: TaskGroupContext = TaskGroupContext()
44
+ # postponing state creation to include disposables if needed
42
45
  self._state_context: StateContext
43
46
  self._state: tuple[State, ...] = state
44
47
  self._disposables: Disposables | None = disposables
45
- self._metrics_context: MetricsContext
46
- self._completion: Callable[[ScopeMetrics], Coroutine[None, None, None]] | None = completion
48
+ # pre-building metrics context to ensure nested context registering
49
+ self._metrics_context: MetricsContext = MetricsContext.scope(
50
+ name,
51
+ logger=logger,
52
+ trace_id=trace_id,
53
+ completion=completion,
54
+ )
47
55
 
48
56
  freeze(self)
49
57
 
50
58
  def __enter__(self) -> None:
51
- assert self._completion is None, "Can't enter synchronous context with completion" # nosec: B101
52
59
  assert self._disposables is None, "Can't enter synchronous context with disposables" # nosec: B101
53
-
54
60
  self._state_context = StateContext.updated(self._state)
55
- self._metrics_context = MetricsContext.scope(
56
- self._name,
57
- logger=self._logger,
58
- trace_id=self._trace_id,
59
- )
60
-
61
61
  self._state_context.__enter__()
62
62
  self._metrics_context.__enter__()
63
63
 
@@ -80,9 +80,9 @@ class ScopeContext:
80
80
  )
81
81
 
82
82
  async def __aenter__(self) -> None:
83
- await self._task_group.__aenter__()
83
+ await self._task_group_context.__aenter__()
84
84
 
85
- if self._disposables:
85
+ if self._disposables is not None:
86
86
  self._state_context = StateContext.updated(
87
87
  (*self._state, *await self._disposables.__aenter__())
88
88
  )
@@ -90,12 +90,6 @@ class ScopeContext:
90
90
  else:
91
91
  self._state_context = StateContext.updated(self._state)
92
92
 
93
- self._metrics_context = MetricsContext.scope(
94
- self._name,
95
- logger=self._logger,
96
- trace_id=self._trace_id,
97
- )
98
-
99
93
  self._state_context.__enter__()
100
94
  self._metrics_context.__enter__()
101
95
 
@@ -105,14 +99,14 @@ class ScopeContext:
105
99
  exc_val: BaseException | None,
106
100
  exc_tb: TracebackType | None,
107
101
  ) -> None:
108
- if self._disposables:
102
+ if self._disposables is not None:
109
103
  await self._disposables.__aexit__(
110
104
  exc_type=exc_type,
111
105
  exc_val=exc_val,
112
106
  exc_tb=exc_tb,
113
107
  )
114
108
 
115
- await self._task_group.__aexit__(
109
+ await self._task_group_context.__aexit__(
116
110
  exc_type=exc_type,
117
111
  exc_val=exc_val,
118
112
  exc_tb=exc_tb,
@@ -130,9 +124,6 @@ class ScopeContext:
130
124
  exc_tb=exc_tb,
131
125
  )
132
126
 
133
- if completion := self._completion:
134
- await completion(self._metrics_context._metrics) # pyright: ignore[reportPrivateUsage]
135
-
136
127
 
137
128
  @final
138
129
  class ctx:
@@ -144,7 +135,9 @@ class ctx:
144
135
  disposables: Disposables | Iterable[Disposable] | None = None,
145
136
  logger: Logger | None = None,
146
137
  trace_id: str | None = None,
147
- completion: Callable[[ScopeMetrics], Coroutine[None, None, None]] | None = None,
138
+ completion: Callable[[ScopeMetrics], Coroutine[None, None, None]]
139
+ | Callable[[ScopeMetrics], None]
140
+ | None = None,
148
141
  ) -> ScopeContext:
149
142
  """
150
143
  Access scope context with given parameters. When called within an existing context\
@@ -173,7 +166,7 @@ class ctx:
173
166
  provided current identifier will be used if any, otherwise it random id will\
174
167
  be generated
175
168
 
176
- completion: Callable[[ScopeMetrics], Coroutine[None, None, None]] | None = None
169
+ completion: Callable[[ScopeMetrics], Coroutine[None, None, None]] | Callable[[ScopeMetrics], None] | None = None
177
170
  completion callback called on exit from the scope granting access to finished\
178
171
  scope metrics. Completion is called outside of the context when its metrics is\
179
172
  already finished. Make sure to avoid any long operations within the completion.
@@ -182,7 +175,7 @@ class ctx:
182
175
  -------
183
176
  ScopeContext
184
177
  context object intended to enter context manager with it
185
- """
178
+ """ # noqa: E501
186
179
 
187
180
  resolved_disposables: Disposables | None
188
181
  match disposables:
@@ -201,7 +194,6 @@ class ctx:
201
194
  logger=logger,
202
195
  state=state,
203
196
  disposables=resolved_disposables,
204
- task_group=TaskGroupContext(),
205
197
  completion=completion,
206
198
  )
207
199
 
@@ -257,6 +249,62 @@ class ctx:
257
249
 
258
250
  return TaskGroupContext.run(function, *args, **kwargs)
259
251
 
252
+ @staticmethod
253
+ def stream[Result, **Arguments](
254
+ source: Callable[Arguments, AsyncGenerator[Result, None]],
255
+ /,
256
+ *args: Arguments.args,
257
+ **kwargs: Arguments.kwargs,
258
+ ) -> AsyncIterator[Result]:
259
+ """
260
+ Stream results produced by a generator within the proper context state.
261
+
262
+ Parameters
263
+ ----------
264
+ source: Callable[Arguments, AsyncGenerator[Result, None]]
265
+ generator streamed as the result
266
+
267
+ *args: Arguments.args
268
+ positional arguments passed to generator call
269
+
270
+ **kwargs: Arguments.kwargs
271
+ keyword arguments passed to generator call
272
+
273
+ Returns
274
+ -------
275
+ AsyncIterator[Result]
276
+ iterator for accessing generated results
277
+ """
278
+
279
+ # prepare context snapshot
280
+ context_snapshot: Context = copy_context()
281
+
282
+ # prepare nested context
283
+ streaming_context: ScopeContext = ctx.scope(
284
+ getattr(
285
+ source,
286
+ "__name__",
287
+ "streaming",
288
+ )
289
+ )
290
+
291
+ async def generator() -> AsyncGenerator[Result, None]:
292
+ async with streaming_context:
293
+ async for result in source(*args, **kwargs):
294
+ yield result
295
+
296
+ # finally return it as an iterator
297
+ return context_snapshot.run(generator)
298
+
299
+ @staticmethod
300
+ def check_cancellation() -> None:
301
+ """
302
+ Check if current asyncio task is cancelled, raises CancelledError if so.
303
+ """
304
+
305
+ if (task := current_task()) and task.cancelled():
306
+ raise CancelledError()
307
+
260
308
  @staticmethod
261
309
  def cancel() -> None:
262
310
  """
haiway/context/metrics.py CHANGED
@@ -1,5 +1,12 @@
1
- from asyncio import Future, gather, get_event_loop
2
- from collections.abc import Callable
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
3
10
  from contextvars import ContextVar, Token
4
11
  from copy import copy
5
12
  from itertools import chain
@@ -27,6 +34,8 @@ class ScopeMetrics:
27
34
  trace_id: str | None,
28
35
  scope: str,
29
36
  logger: Logger | None,
37
+ parent: Self | None,
38
+ completion: Callable[[Self], Coroutine[None, None, None]] | Callable[[Self], None] | None,
30
39
  ) -> None:
31
40
  self.trace_id: str = trace_id or uuid4().hex
32
41
  self.identifier: str = uuid4().hex
@@ -37,15 +46,38 @@ class ScopeMetrics:
37
46
  else f"[{self.trace_id}] [{self.identifier}]"
38
47
  )
39
48
  self._logger: Logger = logger or getLogger(name=scope)
49
+ self._parent: Self | None = parent if parent else None
40
50
  self._metrics: dict[type[State], State] = {}
41
- self._nested: list[ScopeMetrics] = []
51
+ self._nested: set[ScopeMetrics] = set()
42
52
  self._timestamp: float = monotonic()
43
- self._completed: Future[float] = get_event_loop().create_future()
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.add(self)
44
59
 
45
60
  freeze(self)
46
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
+
47
79
  def __del__(self) -> None:
48
- self._complete() # ensure completion on deinit
80
+ assert self.is_completed, "Deinitializing not completed scope metrics" # nosec: B101
49
81
 
50
82
  def __str__(self) -> str:
51
83
  return f"{self.label}[{self.identifier}]@[{self.trace_id}]"
@@ -113,8 +145,8 @@ class ScopeMetrics:
113
145
  self._metrics[metric_type] = metric
114
146
 
115
147
  @property
116
- def completed(self) -> bool:
117
- return self._completed.done() and all(nested.completed for nested in self._nested)
148
+ def is_completed(self) -> bool:
149
+ return self._completed.done() and all(nested.is_completed for nested in self._nested)
118
150
 
119
151
  @property
120
152
  def time(self) -> float:
@@ -131,24 +163,36 @@ class ScopeMetrics:
131
163
  return_exceptions=False,
132
164
  )
133
165
 
134
- def _complete(self) -> None:
135
- if self._completed.done():
136
- return # already completed
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"
137
183
 
184
+ if not self._finished:
185
+ return # wait for finishing self
186
+
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
138
191
  self._completed.set_result(monotonic() - self._timestamp)
139
192
 
140
- def scope(
141
- self,
142
- name: str,
143
- /,
144
- ) -> Self:
145
- nested: Self = self.__class__(
146
- scope=name,
147
- logger=self._logger,
148
- trace_id=self.trace_id,
149
- )
150
- self._nested.append(nested)
151
- return nested
193
+ # notify parent about completion
194
+ if parent := self._parent:
195
+ parent._complete_if_able()
152
196
 
153
197
  def log(
154
198
  self,
@@ -178,29 +222,37 @@ class MetricsContext:
178
222
  *,
179
223
  trace_id: str | None = None,
180
224
  logger: Logger | None = None,
225
+ completion: Callable[[ScopeMetrics], Coroutine[None, None, None]]
226
+ | Callable[[ScopeMetrics], None]
227
+ | None,
181
228
  ) -> Self:
182
- try:
183
- context: ScopeMetrics = cls._context.get()
184
- if trace_id is None or context.trace_id == trace_id:
185
- return cls(context.scope(name))
229
+ current: ScopeMetrics
230
+ try: # check for current scope context
231
+ current = cls._context.get()
186
232
 
187
- else:
188
- return cls(
189
- ScopeMetrics(
190
- trace_id=trace_id,
191
- scope=name,
192
- logger=logger or context._logger, # pyright: ignore[reportPrivateUsage]
193
- )
194
- )
195
- except LookupError: # create metrics scope when missing yet
233
+ except LookupError:
234
+ # create metrics scope when missing yet
196
235
  return cls(
197
236
  ScopeMetrics(
198
237
  trace_id=trace_id,
199
238
  scope=name,
200
239
  logger=logger,
240
+ parent=None,
241
+ completion=completion,
201
242
  )
202
243
  )
203
244
 
245
+ # or create nested metrics otherwise
246
+ 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
+ )
254
+ )
255
+
204
256
  @classmethod
205
257
  def record[Metric: State](
206
258
  cls,
@@ -320,15 +372,12 @@ class MetricsContext:
320
372
  ) -> None:
321
373
  self._metrics: ScopeMetrics = metrics
322
374
  self._token: Token[ScopeMetrics] | None = None
323
- self._started: float | None = None
324
- self._finished: float | None = None
325
375
 
326
376
  def __enter__(self) -> None:
327
377
  assert ( # nosec: B101
328
- self._token is None and self._started is None
378
+ self._token is None and not self._metrics._finished # pyright: ignore[reportPrivateUsage]
329
379
  ), "MetricsContext reentrance is not allowed"
330
380
  self._token = MetricsContext._context.set(self._metrics)
331
- self._started = monotonic()
332
381
 
333
382
  def __exit__(
334
383
  self,
@@ -337,8 +386,8 @@ class MetricsContext:
337
386
  exc_tb: TracebackType | None,
338
387
  ) -> None:
339
388
  assert ( # nosec: B101
340
- self._token is not None and self._started is not None and self._finished is None
389
+ self._token is not None
341
390
  ), "Unbalanced MetricsContext context enter/exit"
342
- self._finished = monotonic()
343
391
  MetricsContext._context.reset(self._token)
392
+ self._metrics._finish() # pyright: ignore[reportPrivateUsage]
344
393
  self._token = None
haiway/utils/queue.py CHANGED
@@ -3,8 +3,6 @@ from collections import deque
3
3
  from collections.abc import AsyncIterator
4
4
  from typing import Self
5
5
 
6
- from haiway.utils.immutable import freeze
7
-
8
6
  __all__ = [
9
7
  "AsyncQueue",
10
8
  ]
@@ -18,20 +16,19 @@ class AsyncQueue[Element](AsyncIterator[Element]):
18
16
 
19
17
  def __init__(
20
18
  self,
19
+ *elements: Element,
21
20
  loop: AbstractEventLoop | None = None,
22
21
  ) -> None:
23
22
  self._loop: AbstractEventLoop = loop or get_running_loop()
24
- self._queue: deque[Element] = deque()
23
+ self._queue: deque[Element] = deque(elements)
25
24
  self._waiting: Future[Element] | None = None
26
25
  self._finish_reason: BaseException | None = None
27
26
 
28
- freeze(self)
29
-
30
27
  def __del__(self) -> None:
31
28
  self.finish()
32
29
 
33
30
  @property
34
- def finished(self) -> bool:
31
+ def is_finished(self) -> bool:
35
32
  return self._finish_reason is not None
36
33
 
37
34
  def enqueue(
@@ -40,7 +37,7 @@ class AsyncQueue[Element](AsyncIterator[Element]):
40
37
  /,
41
38
  *elements: Element,
42
39
  ) -> None:
43
- if self.finished:
40
+ if self.is_finished:
44
41
  raise RuntimeError("AsyncQueue is already finished")
45
42
 
46
43
  if self._waiting is not None and not self._waiting.done():
@@ -55,7 +52,7 @@ class AsyncQueue[Element](AsyncIterator[Element]):
55
52
  self,
56
53
  exception: BaseException | None = None,
57
54
  ) -> None:
58
- if self.finished:
55
+ if self.is_finished:
59
56
  return # already finished, ignore
60
57
 
61
58
  self._finish_reason = exception or StopAsyncIteration()
@@ -70,7 +67,7 @@ class AsyncQueue[Element](AsyncIterator[Element]):
70
67
  return self
71
68
 
72
69
  async def __anext__(self) -> Element:
73
- assert self._waiting is None, "Only a single queue iterator is supported!" # nosec: B101
70
+ assert self._waiting is None, "Only a single queue consumer is supported!" # nosec: B101
74
71
 
75
72
  if self._queue: # check the queue, let it finish
76
73
  return self._queue.popleft()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: haiway
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Framework for dependency injection and state management within structured concurrency model.
5
5
  Maintainer-email: Kacper Kaliński <kacper.kalinski@miquido.com>
6
6
  License: MIT License
@@ -1,9 +1,9 @@
1
1
  haiway/__init__.py,sha256=hLc3-FDmNQEV4r-RLOiGjWtYSk7krU8vRMBaZyRU08g,1267
2
2
  haiway/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  haiway/context/__init__.py,sha256=21Y3zvRo1bHASZD6B_FNkU28k1-g88RdmUyqxvYXJxg,336
4
- haiway/context/access.py,sha256=E9aIC1RUupKk2LXik5qb13oHpW2s_tNQC0UxGcO9xr0,12761
4
+ haiway/context/access.py,sha256=zPQcQBp5XMlNuszbxhtzi-5mNpDLpZ6PT-gVFDBH0dA,14115
5
5
  haiway/context/disposables.py,sha256=VQX9jVo1pjqkmOYzWpsbYyF45y0XtpjorIIaeMBCwTU,1771
6
- haiway/context/metrics.py,sha256=TGZzNrmGPolAwVb1mBEF10mXRyVyBq-MHTucAokNuhY,9105
6
+ haiway/context/metrics.py,sha256=R6BIH3Pwtm_fQqPZmTei3nUGNDAZEnvUKaVxwjWxgS0,10755
7
7
  haiway/context/state.py,sha256=GxGwPQTK8FdSprBd83lQbA9veubp0o93_1Yk3gb7HMc,3000
8
8
  haiway/context/tasks.py,sha256=xXtXIUwXOra0EePTdkcEbMOmpWwFcO3hCRfR_IfvAHk,1978
9
9
  haiway/context/types.py,sha256=VvJA7wAPZ3ISpgyThVguioYUXqhHf0XkPfRd0M1ERiQ,142
@@ -28,9 +28,9 @@ haiway/utils/immutable.py,sha256=K34ZIMzbkpgkHKH-KF73plEbXExsajNRkRTYp9nJEf4,620
28
28
  haiway/utils/logs.py,sha256=oDsc1ZdqKDjlTlctLbDcp9iX98Acr-1tdw-Pyg3DElo,1577
29
29
  haiway/utils/mimic.py,sha256=BkVjTVP2TxxC8GChPGyDV6UXVwJmiRiSWeOYZNZFHxs,1828
30
30
  haiway/utils/noop.py,sha256=qgbZlOKWY6_23Zs43OLukK2HagIQKRyR04zrFVm5rWI,344
31
- haiway/utils/queue.py,sha256=WGW8kSusIwRYHsYRIKD2CaqhhC1pUtVgtNHFDXDtYrw,2443
32
- haiway-0.3.2.dist-info/LICENSE,sha256=GehQEW_I1pkmxkkj3NEa7rCTQKYBn7vTPabpDYJlRuo,1063
33
- haiway-0.3.2.dist-info/METADATA,sha256=Ze8RmuhIj2Ck3SVea8h3VbaTSf43_Y57oISLZKytNOA,3872
34
- haiway-0.3.2.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
35
- haiway-0.3.2.dist-info/top_level.txt,sha256=_LdXVLzUzgkvAGQnQJj5kQfoFhpPW6EF4Kj9NapniLg,7
36
- haiway-0.3.2.dist-info/RECORD,,
31
+ haiway/utils/queue.py,sha256=7gLpL07E4K_FnP1AygmpNnBpwfS5kgnw_6wakuRkmw4,2423
32
+ haiway-0.4.0.dist-info/LICENSE,sha256=GehQEW_I1pkmxkkj3NEa7rCTQKYBn7vTPabpDYJlRuo,1063
33
+ haiway-0.4.0.dist-info/METADATA,sha256=7inuv-8w44WoEhdlvmdPJkrbb-uz_pQ85KCpypIGf7w,3872
34
+ haiway-0.4.0.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
35
+ haiway-0.4.0.dist-info/top_level.txt,sha256=_LdXVLzUzgkvAGQnQJj5kQfoFhpPW6EF4Kj9NapniLg,7
36
+ haiway-0.4.0.dist-info/RECORD,,
File without changes