flowevent 1.2__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.
@@ -0,0 +1,95 @@
1
+ """Copy-on-change ordered registries; callers serialize configuration changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ from dataclasses import dataclass, replace
7
+ from typing import Generic, TypeVar
8
+
9
+ C = TypeVar("C")
10
+
11
+
12
+ def _name(value: str) -> str:
13
+ if not isinstance(value, str) or not value.strip():
14
+ raise ValueError("Pipeline component name must be a non-empty string")
15
+ return value.strip()
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class _Entry(Generic[C]):
20
+ name: str
21
+ callback: C
22
+ before: str | None = None
23
+ after: str | None = None
24
+ enabled: bool = True
25
+
26
+
27
+ class _Registry(Generic[C]):
28
+ def __init__(self) -> None:
29
+ self._entries: dict[str, _Entry[C]] = {}
30
+ self.ordered: tuple[_Entry[C], ...] = ()
31
+ self.plan: tuple[_Entry[C], ...] = ()
32
+
33
+ def _commit(self, entries: dict[str, _Entry[C]]) -> None:
34
+ positions = {name: index for index, name in enumerate(entries)}
35
+ edges: dict[str, set[str]] = {name: set() for name in entries}
36
+ degree = dict.fromkeys(entries, 0)
37
+ for entry in entries.values():
38
+ for source, target in ((entry.name, entry.before), (entry.after, entry.name)):
39
+ if source is None or target is None:
40
+ continue
41
+ if source not in entries or target not in entries:
42
+ raise KeyError(f"Unknown pipeline ordering reference: {source!r}, {target!r}")
43
+ if target not in edges[source]:
44
+ edges[source].add(target)
45
+ degree[target] += 1
46
+ ready = [(positions[name], name) for name, count in degree.items() if count == 0]
47
+ heapq.heapify(ready)
48
+ ordered = []
49
+ while ready:
50
+ _, name = heapq.heappop(ready)
51
+ ordered.append(entries[name])
52
+ for target in edges[name]:
53
+ degree[target] -= 1
54
+ if degree[target] == 0:
55
+ heapq.heappush(ready, (positions[target], target))
56
+ if len(ordered) != len(entries):
57
+ raise ValueError("Pipeline ordering contains a cycle")
58
+ self._entries = entries
59
+ self.ordered = tuple(ordered)
60
+ self.plan = tuple(entry for entry in ordered if entry.enabled)
61
+
62
+ def add(self, name: str, callback: C, before: str | None, after: str | None) -> None:
63
+ name = _name(name)
64
+ if name in self._entries:
65
+ raise ValueError(f"Pipeline component already exists: {name}")
66
+ entry = _Entry(
67
+ name,
68
+ callback,
69
+ _name(before) if before is not None else None,
70
+ _name(after) if after is not None else None,
71
+ )
72
+ self._commit({**self._entries, name: entry})
73
+
74
+ def remove(self, name: str) -> C:
75
+ name = _name(name)
76
+ removed = self._entries[name]
77
+ entries = {
78
+ key: replace(
79
+ value,
80
+ before=None if value.before == name else value.before,
81
+ after=None if value.after == name else value.after,
82
+ )
83
+ for key, value in self._entries.items()
84
+ if key != name
85
+ }
86
+ self._commit(entries)
87
+ return removed.callback
88
+
89
+ def replace(self, name: str, callback: C) -> None:
90
+ name = _name(name)
91
+ self._commit({**self._entries, name: replace(self._entries[name], callback=callback)})
92
+
93
+ def enable(self, name: str, enabled: bool) -> None:
94
+ name = _name(name)
95
+ self._commit({**self._entries, name: replace(self._entries[name], enabled=enabled)})
@@ -0,0 +1,92 @@
1
+ """Thread-safe configuration and snapshot execution for synchronous pipelines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from threading import get_ident
7
+ from time import perf_counter
8
+ from typing import Generic, Literal, TypeVar
9
+
10
+ from .._callbacks import synchronous_result
11
+ from .base import _PipelineBase
12
+ from .registry import _Entry
13
+
14
+ T = TypeVar("T")
15
+ Stage = Callable[[T], T]
16
+ Middleware = Callable[[T, Callable[[T], T]], T]
17
+
18
+
19
+ class Pipeline(_PipelineBase[Stage[T], Middleware[T]], Generic[T]):
20
+ """Run same-type transformations with optional around middleware.
21
+
22
+ Each run uses a compiled immutable plan. Configuration changes apply to
23
+ future runs. Payload mutation is the caller's responsibility.
24
+ """
25
+
26
+ def _invoke(
27
+ self,
28
+ name: str,
29
+ phase: Literal["stage", "middleware"],
30
+ callback: Callable[..., T],
31
+ context: T,
32
+ continuation: Callable[[T], T] | None = None,
33
+ ) -> T:
34
+ started = perf_counter() if self._observer is not None else 0.0
35
+ try:
36
+ result = callback(context) if continuation is None else callback(context, continuation)
37
+ result = synchronous_result(result)
38
+ except BaseException as error:
39
+ self._raise(error, self._observe(phase, name, started, error), name, phase, context)
40
+ diagnostic = self._observe(phase, name, started)
41
+ if diagnostic is not None:
42
+ self._raise(diagnostic, None, name, "observer", context)
43
+ return result
44
+
45
+ def _compile(self) -> None:
46
+ stages = self._stages.plan
47
+
48
+ def execute(context: T) -> T:
49
+ for entry in stages:
50
+ context = self._invoke(entry.name, "stage", entry.callback, context)
51
+ return context
52
+
53
+ chain = execute
54
+ for entry in reversed(self._middleware.plan):
55
+ chain = self._layer(entry, chain)
56
+ self._execution = chain
57
+
58
+ def _layer(self, entry: _Entry[Middleware[T]], following: Callable[[T], T]) -> Callable[[T], T]:
59
+ def execute(context: T) -> T:
60
+ used = False
61
+ active = True
62
+ owner = get_ident()
63
+
64
+ def next_(value: T) -> T:
65
+ nonlocal used
66
+ if used or not active or get_ident() != owner:
67
+ raise RuntimeError(
68
+ "Middleware continuation is single-use and scoped to its call"
69
+ )
70
+ used = True
71
+ return following(value)
72
+
73
+ try:
74
+ return self._invoke(entry.name, "middleware", entry.callback, context, next_)
75
+ finally:
76
+ active = False
77
+
78
+ return execute
79
+
80
+ def run(self, context: T) -> T:
81
+ """Execute one snapshot; failures preserve their cause and stop later stages."""
82
+ with self._lock:
83
+ execute = self._execution
84
+ started = perf_counter() if self._observer is not None else 0.0
85
+ try:
86
+ result = execute(context)
87
+ except BaseException as error:
88
+ self._raise(error, self._observe("run", None, started, error), None, "run", context)
89
+ diagnostic = self._observe("run", None, started)
90
+ if diagnostic is not None:
91
+ self._raise(diagnostic, None, None, "observer", context)
92
+ return result
eventflow/py.typed ADDED
File without changes
eventflow/scopes.py ADDED
@@ -0,0 +1,185 @@
1
+ """Explicit ownership of existing subscription and cancellation handles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import threading
8
+ from collections.abc import Awaitable
9
+ from typing import Protocol, TypeVar
10
+
11
+ from ._callbacks import is_async_callable, synchronous_result
12
+ from ._lifecycle import finish_cleanup
13
+ from .compatibility import BaseExceptionGroup, ExceptionGroup
14
+
15
+ __all__ = ["CancelHandle", "AsyncCancelHandle", "SubscriptionScope", "AsyncSubscriptionScope"]
16
+
17
+
18
+ class CancelHandle(Protocol):
19
+ """A synchronous, preferably idempotent cancellation handle."""
20
+
21
+ def cancel(self) -> None: ...
22
+
23
+
24
+ class AsyncCancelHandle(Protocol):
25
+ """A handle whose cancel method returns an awaitable, preferably idempotently."""
26
+
27
+ def cancel(self) -> Awaitable[None]: ...
28
+
29
+
30
+ H = TypeVar("H", bound=CancelHandle)
31
+ A = TypeVar("A", bound=AsyncCancelHandle)
32
+
33
+
34
+ def _raise_cleanup_errors(errors: list[BaseException]) -> None:
35
+ if not errors:
36
+ return
37
+ for index, error in enumerate(errors):
38
+ if not isinstance(error, Exception):
39
+ remaining = errors[:index] + errors[index + 1 :]
40
+ if remaining:
41
+ raise error from BaseExceptionGroup("Subscription scope cleanup failed", remaining)
42
+ raise error
43
+ ordinary = [error for error in errors if isinstance(error, Exception)]
44
+ raise ExceptionGroup("Subscription scope cleanup failed", ordinary)
45
+
46
+
47
+ class SubscriptionScope:
48
+ """Cancel owned handles in reverse registration order on exit.
49
+
50
+ All handles are attempted. Failed handles remain owned for a later retry.
51
+ A closed scope rejects additions, including after an unsuccessful close.
52
+ """
53
+
54
+ def __init__(self) -> None:
55
+ self._handles: list[CancelHandle] = []
56
+ self._lock = threading.RLock()
57
+ self._close_lock = threading.RLock()
58
+ self._closed = False
59
+
60
+ @property
61
+ def closed(self) -> bool:
62
+ """Whether the scope has stopped accepting handles."""
63
+ with self._lock:
64
+ return self._closed
65
+
66
+ def add(self, handle: H) -> H:
67
+ """Own a handle and return it unchanged; duplicate identities are ignored."""
68
+ cancel = getattr(handle, "cancel", None)
69
+ if not callable(cancel) or is_async_callable(cancel):
70
+ raise TypeError("SubscriptionScope requires a synchronous cancel handle")
71
+ with self._lock:
72
+ if self._closed:
73
+ raise RuntimeError("Cannot add a handle to a closed subscription scope")
74
+ if not any(item is handle for item in self._handles):
75
+ self._handles.append(handle)
76
+ return handle
77
+
78
+ def close(self) -> None:
79
+ """Attempt every cancellation, retaining failures and reporting them together."""
80
+ with self._close_lock:
81
+ with self._lock:
82
+ self._closed = True
83
+ pending, self._handles = list(reversed(self._handles)), []
84
+ failed: list[CancelHandle] = []
85
+ errors: list[BaseException] = []
86
+ for handle in pending:
87
+ try:
88
+ synchronous_result(handle.cancel())
89
+ except BaseException as error:
90
+ failed.append(handle)
91
+ errors.append(error)
92
+ with self._lock:
93
+ self._handles = list(reversed(failed))
94
+ _raise_cleanup_errors(errors)
95
+
96
+ def __enter__(self) -> SubscriptionScope:
97
+ return self
98
+
99
+ def __exit__(self, *_args: object) -> None:
100
+ self.close()
101
+
102
+
103
+ class AsyncSubscriptionScope:
104
+ """Own async handles on one loop and finish cleanup despite caller cancellation.
105
+
106
+ Cleanup runs sequentially in reverse registration order. Concurrent aclose
107
+ callers await the same cleanup task. Failed handles can be retried.
108
+ """
109
+
110
+ def __init__(self) -> None:
111
+ self._handles: list[AsyncCancelHandle] = []
112
+ self._closed = False
113
+ self._loop: asyncio.AbstractEventLoop | None = None
114
+ self._cleanup: asyncio.Task[list[BaseException]] | None = None
115
+
116
+ def _bind_loop(self) -> None:
117
+ loop = asyncio.get_running_loop()
118
+ if self._loop is None:
119
+ self._loop = loop
120
+ elif self._loop is not loop:
121
+ raise RuntimeError("AsyncSubscriptionScope belongs to a different event loop")
122
+
123
+ @property
124
+ def closed(self) -> bool:
125
+ """Whether the scope has stopped accepting handles."""
126
+ return self._closed
127
+
128
+ def add(self, handle: A) -> A:
129
+ """Own an awaitable cancel handle and return it unchanged.
130
+
131
+ The cancel method is not called here because doing so would cancel the
132
+ handle. Its return value is validated when the scope closes.
133
+ """
134
+ if not callable(getattr(handle, "cancel", None)):
135
+ raise TypeError("AsyncSubscriptionScope requires an awaitable cancel handle")
136
+ if self._loop is not None:
137
+ self._bind_loop()
138
+ if self._closed:
139
+ raise RuntimeError("Cannot add a handle to a closed subscription scope")
140
+ if not any(item is handle for item in self._handles):
141
+ self._handles.append(handle)
142
+ return handle
143
+
144
+ async def _cancel_handles(self) -> list[BaseException]:
145
+ pending, self._handles = list(reversed(self._handles)), []
146
+ failed: list[AsyncCancelHandle] = []
147
+ errors: list[BaseException] = []
148
+ for handle in pending:
149
+ try:
150
+ operation = handle.cancel()
151
+ if not inspect.isawaitable(operation):
152
+ raise TypeError("Async cancel handle cancel() must return an awaitable")
153
+ synchronous_result(await operation)
154
+ except BaseException as error:
155
+ failed.append(handle)
156
+ errors.append(error)
157
+ self._handles = list(reversed(failed))
158
+ return errors
159
+
160
+ async def aclose(self) -> None:
161
+ """Finish all cancellations; caller cancellation propagates after cleanup."""
162
+ self._bind_loop()
163
+ self._closed = True
164
+ if self._cleanup is None or (self._cleanup.done() and self._handles):
165
+ self._cleanup = asyncio.create_task(
166
+ self._cancel_handles(), name="EventFlowScopeCleanup"
167
+ )
168
+ try:
169
+ errors = await finish_cleanup(self._cleanup)
170
+ except asyncio.CancelledError as cancellation:
171
+ if self._cleanup.done() and not self._cleanup.cancelled():
172
+ errors = self._cleanup.result()
173
+ if errors:
174
+ raise cancellation from BaseExceptionGroup(
175
+ "Subscription scope cleanup failed", errors
176
+ )
177
+ raise
178
+ _raise_cleanup_errors(errors)
179
+
180
+ async def __aenter__(self) -> AsyncSubscriptionScope:
181
+ self._bind_loop()
182
+ return self
183
+
184
+ async def __aexit__(self, *_args: object) -> None:
185
+ await self.aclose()
eventflow/stream.py ADDED
@@ -0,0 +1,241 @@
1
+ """Lazy single-consumer async streams with opt-in bounded prefetch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable
8
+ from typing import Any, Generic, NoReturn, TypeVar, cast
9
+
10
+ from ._lifecycle import finish_cleanup
11
+ from .base import validate_positive_capacity
12
+
13
+ T = TypeVar("T")
14
+ U = TypeVar("U")
15
+ __all__ = ["AsyncStream"]
16
+
17
+
18
+ def _raise_with_cleanup(primary: BaseException, cleanup: BaseException) -> NoReturn:
19
+ if cleanup is primary:
20
+ raise primary
21
+ if isinstance(cleanup, Exception):
22
+ raise primary from cleanup
23
+ raise cleanup from primary
24
+
25
+
26
+ class AsyncStream(AsyncIterator[T], Generic[T]):
27
+ """Own one async source and consume it once on one event loop.
28
+
29
+ map/filter/batch are lazy and create no worker tasks. Use ``async with``
30
+ around the terminal stream for deterministic cleanup on an early break.
31
+ aclose cancels an outstanding read, then closes sources and producers.
32
+ """
33
+
34
+ def __init__(
35
+ self, source: AsyncIterable[T], *, _upstream: AsyncStream[Any] | None = None
36
+ ) -> None:
37
+ if not isinstance(source, AsyncIterable):
38
+ raise TypeError("AsyncStream source must be async iterable")
39
+ self._source = source
40
+ self._upstream = _upstream
41
+ self._iterator: AsyncIterator[T] | None = None
42
+ self._claimed = False
43
+ self._closed = False
44
+ self._loop: asyncio.AbstractEventLoop | None = None
45
+ self._consumer: asyncio.Task[Any] | None = None
46
+ self._reader: asyncio.Task[Any] | None = None
47
+ self._read_finished: asyncio.Event | None = None
48
+ self._cleanup: asyncio.Task[None] | None = None
49
+
50
+ def _bind_loop(self) -> None:
51
+ loop = asyncio.get_running_loop()
52
+ if self._loop is None:
53
+ self._loop = loop
54
+ elif self._loop is not loop:
55
+ raise RuntimeError("AsyncStream belongs to a different event loop")
56
+
57
+ @property
58
+ def closed(self) -> bool:
59
+ """Whether the stream no longer accepts reads."""
60
+ return self._closed
61
+
62
+ def __aiter__(self) -> AsyncStream[T]:
63
+ self._bind_loop()
64
+ if self._claimed:
65
+ raise RuntimeError("AsyncStream supports one consumption only")
66
+ if self._closed:
67
+ raise RuntimeError("AsyncStream is closed")
68
+ self._claimed = True
69
+ return self
70
+
71
+ async def __anext__(self) -> T:
72
+ self._bind_loop()
73
+ if self._closed:
74
+ raise StopAsyncIteration
75
+ if self._reader is not None:
76
+ raise RuntimeError("Concurrent reads from one AsyncStream are not supported")
77
+ task = asyncio.current_task()
78
+ if self._consumer is None:
79
+ self._consumer = task
80
+ elif self._consumer is not task:
81
+ raise RuntimeError("AsyncStream must be consumed by one task")
82
+ self._claimed = True
83
+ self._reader = task
84
+ try:
85
+ if self._iterator is None:
86
+ self._iterator = self._source.__aiter__()
87
+ return await self._iterator.__anext__()
88
+ except BaseException as error:
89
+ self._reader = None
90
+ if not self._closed:
91
+ try:
92
+ await self.aclose()
93
+ except BaseException as cleanup_error:
94
+ if isinstance(error, StopAsyncIteration):
95
+ raise cleanup_error
96
+ _raise_with_cleanup(error, cleanup_error)
97
+ raise
98
+ finally:
99
+ self._reader = None
100
+ if self._read_finished is not None:
101
+ self._read_finished.set()
102
+ self._read_finished = None
103
+
104
+ def map(self, callback: Callable[[T], U | Awaitable[U]]) -> AsyncStream[U]:
105
+ """Transform each item with a sync or awaitable callback, without threads."""
106
+ if not callable(callback):
107
+ raise TypeError("Stream map callback must be callable")
108
+
109
+ async def transformed() -> AsyncIterator[U]:
110
+ async for item in self:
111
+ result = callback(item)
112
+ yield await result if inspect.isawaitable(result) else cast(U, result)
113
+
114
+ return AsyncStream(transformed(), _upstream=self)
115
+
116
+ def filter(self, predicate: Callable[[T], bool | Awaitable[bool]]) -> AsyncStream[T]:
117
+ """Keep matching items using a synchronous or awaitable predicate."""
118
+ if not callable(predicate):
119
+ raise TypeError("Stream filter predicate must be callable")
120
+
121
+ async def filtered() -> AsyncIterator[T]:
122
+ async for item in self:
123
+ accepted = predicate(item)
124
+ if inspect.isawaitable(accepted):
125
+ accepted = await accepted
126
+ if type(accepted) is not bool:
127
+ raise TypeError("Stream filter predicates must return bool")
128
+ if accepted:
129
+ yield item
130
+
131
+ return AsyncStream(filtered(), _upstream=self)
132
+
133
+ def batch(self, size: int, *, partial: bool = True) -> AsyncStream[tuple[T, ...]]:
134
+ """Yield fixed-size tuples and optionally the final partial tuple."""
135
+ size = validate_positive_capacity(size, name="Stream batch size")
136
+ if not isinstance(partial, bool):
137
+ raise TypeError("Stream partial flag must be a bool")
138
+
139
+ async def batched() -> AsyncIterator[tuple[T, ...]]:
140
+ items = []
141
+ async for item in self:
142
+ items.append(item)
143
+ if len(items) == size:
144
+ yield tuple(items)
145
+ items.clear()
146
+ if items and partial:
147
+ yield tuple(items)
148
+
149
+ return AsyncStream(batched(), _upstream=self)
150
+
151
+ def buffer(self, capacity: int) -> AsyncStream[T]:
152
+ """Prefetch at most capacity queued items plus one in-flight source item.
153
+
154
+ A full queue blocks its single producer. Values are never dropped.
155
+ Source failures propagate after already queued items have been read.
156
+ """
157
+ capacity = validate_positive_capacity(capacity, name="Stream buffer capacity")
158
+
159
+ async def buffered() -> AsyncIterator[T]:
160
+ queue: asyncio.Queue[object] = asyncio.Queue(maxsize=capacity)
161
+ end = object()
162
+ error: BaseException | None = None
163
+ closing = False
164
+
165
+ async def produce() -> None:
166
+ nonlocal error
167
+ try:
168
+ async for item in self:
169
+ await queue.put(item)
170
+ except BaseException as exc:
171
+ if closing and isinstance(exc, asyncio.CancelledError):
172
+ raise
173
+ error = exc
174
+ await queue.put(end)
175
+
176
+ producer = asyncio.create_task(produce(), name="EventFlowStreamProducer")
177
+ try:
178
+ while True:
179
+ item = await queue.get()
180
+ if item is end:
181
+ if error is not None:
182
+ raise error
183
+ break
184
+ yield cast(T, item)
185
+ finally:
186
+ closing = True
187
+ producer.cancel()
188
+ await finish_cleanup(asyncio.gather(producer, return_exceptions=True))
189
+
190
+ return AsyncStream(buffered(), _upstream=self)
191
+
192
+ async def _shutdown(
193
+ self, reader: asyncio.Task[Any] | None, read_finished: asyncio.Event | None
194
+ ) -> None:
195
+ if reader is not None and not reader.done():
196
+ reader.cancel()
197
+ if read_finished is not None:
198
+ await read_finished.wait()
199
+ source_error: BaseException | None = None
200
+ try:
201
+ source = self._iterator if self._iterator is not None else self._source
202
+ close = getattr(source, "aclose", None)
203
+ if close is not None:
204
+ await close()
205
+ except BaseException as error:
206
+ source_error = error
207
+ if self._upstream is not None:
208
+ try:
209
+ await self._upstream.aclose()
210
+ except BaseException as cleanup_error:
211
+ if source_error is not None:
212
+ _raise_with_cleanup(source_error, cleanup_error)
213
+ raise
214
+ if source_error is not None:
215
+ raise source_error
216
+
217
+ async def aclose(self) -> None:
218
+ """Stop reads and await producer/source cleanup, even during repeated cancellation."""
219
+ self._bind_loop()
220
+ if self._reader is asyncio.current_task():
221
+ raise RuntimeError("Cannot close an AsyncStream from its active read task")
222
+ if self._cleanup is None:
223
+ self._closed = True
224
+ reader = self._reader
225
+ if reader is not None:
226
+ # Only external concurrent close needs a read-completion event.
227
+ self._read_finished = asyncio.Event()
228
+ self._cleanup = asyncio.create_task(
229
+ self._shutdown(reader, self._read_finished),
230
+ name="EventFlowStreamCleanup",
231
+ )
232
+ await finish_cleanup(self._cleanup)
233
+
234
+ async def __aenter__(self) -> AsyncStream[T]:
235
+ self._bind_loop()
236
+ if self._closed:
237
+ raise RuntimeError("AsyncStream is closed")
238
+ return self
239
+
240
+ async def __aexit__(self, *_args: object) -> None:
241
+ await self.aclose()