estuary-cdk 0.3.0.dev31__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,176 @@
1
+ from dataclasses import dataclass
2
+ from logging import Logger
3
+ from pydantic import BaseModel
4
+ from typing import Any, TypeVar, Callable, AsyncGenerator, Generic
5
+ import abc
6
+ from types import FrameType
7
+ import asyncio
8
+ import signal
9
+ import sys
10
+ import traceback
11
+
12
+ from .logger import init_logger, FlowLogger
13
+ from .flow import ValidationError
14
+ from .utils import get_running_tasks_info
15
+
16
+ # Request type served by this connector.
17
+ Request = TypeVar("Request", bound=BaseModel)
18
+
19
+
20
+ # stdin_jsonl parses newline-delimited JSON instances of `cls` from stdin and yields them.
21
+ async def stdin_jsonl(cls: type[Request]) -> AsyncGenerator[Request, None]:
22
+ loop = asyncio.get_event_loop()
23
+ reader = asyncio.StreamReader(limit=1 << 27) # 128 MiB.
24
+ protocol = asyncio.StreamReaderProtocol(reader)
25
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
26
+
27
+ while line := await reader.readline():
28
+ request = cls.model_validate_json(line)
29
+ yield request
30
+
31
+
32
+ # A Mixin is a supporting class which implements a utility on behalf of a connector.
33
+ # Mixins may implement a protocol like Python's Asynchronous Context Manager pattern:
34
+ # they're entered prior to handling any requests, and exited after all requests have
35
+ # been fully processed.
36
+ class Mixin:
37
+ # Parameters are positional-only so overrides may name them freely, and the
38
+ # return is Any because serve() discards it while implementations commonly
39
+ # return self.
40
+ async def _mixin_enter(self, log: Logger, /) -> Any: ...
41
+ async def _mixin_exit(self, log: Logger, /) -> Any: ...
42
+
43
+
44
+ @dataclass
45
+ class Stopped(Exception):
46
+ """
47
+ Stopped is raised by a connector's handle() function to indicate that the
48
+ connector has stopped and this process should exit.
49
+
50
+ `error`, if set, is a fatal error condition that caused the connector to exit.
51
+ """
52
+
53
+ error: str | None
54
+
55
+
56
+ # BaseConnector from which all Flow Connectors inherit.
57
+ class BaseConnector(Generic[Request], abc.ABC):
58
+
59
+ # request_class() returns the concrete class instance for Request served
60
+ # by this connector. It's required to implement to enable parsing of
61
+ # Requests prior to their being dispatched by the connector.
62
+ #
63
+ # Python classes are type-erased, so it's not possible to determine the
64
+ # concrete class at runtime using only type annotations.
65
+ # It is an instance method, not a classmethod, because connectors
66
+ # idiomatically override it as `def request_class(self)`; serve() calls it
67
+ # through the instance, so either form works at runtime.
68
+ @abc.abstractmethod
69
+ def request_class(self) -> type[Request]:
70
+ raise NotImplementedError()
71
+
72
+ @abc.abstractmethod
73
+ async def handle(self, log: FlowLogger, request: Request) -> None:
74
+ raise NotImplementedError()
75
+
76
+ # Serve this connector by invoking `handle()` for all incoming instances of
77
+ # Request as a concurrent asyncio.Task using the provided `responder`.
78
+ # When incoming `requests` are exhausted, all pending tasks are awaited
79
+ # before returning.
80
+ # All handler exceptions are caught and logged, and serve() returns an
81
+ # exit code which indicates whether an error occurred.
82
+ async def serve(
83
+ self,
84
+ log: FlowLogger | None = None,
85
+ requests: Callable[
86
+ [type[Request]], AsyncGenerator[Request, None]
87
+ ] = stdin_jsonl,
88
+ ):
89
+ if not log:
90
+ log = init_logger()
91
+
92
+ assert isinstance(log, FlowLogger) # Narrow type to non-None.
93
+
94
+ loop = asyncio.get_running_loop()
95
+ this_task = asyncio.current_task(loop)
96
+ original_sigquit = signal.getsignal(signal.SIGQUIT)
97
+
98
+ def dump_all_tasks(signum: int, frame: FrameType | None):
99
+ task_infos = get_running_tasks_info(exclude_tasks={this_task} if this_task else None)
100
+
101
+ for info in task_infos:
102
+ log.info("Attempting to inject SIGQUIT exception into task.", {
103
+ "task_name": info.task_name,
104
+ "coro_name": info.coro_name,
105
+ })
106
+
107
+ # Reach inside the task coroutine to inject an exception, which
108
+ # will unwind the task stack and lets us print a precise stack trace.
109
+ coro = info.task.get_coro()
110
+ if coro:
111
+ try:
112
+ coro.throw(RuntimeError("injected SIGQUIT exception"))
113
+ except Exception as exc:
114
+ msg, args = type(exc).__name__, exc.args
115
+ if len(args) != 0:
116
+ msg = f"{msg}: {args[0]}"
117
+ log.exception(msg, args)
118
+
119
+ # We manually injected an exception into the coroutine,
120
+ # so the asyncio event loop will attempt to await it again
121
+ # and get a new "cannot reuse already awaited coroutine".
122
+ # Cancel the task now and add a callback to clear the exception.
123
+ info.task.cancel()
124
+ info.task.add_done_callback(lambda task: task.exception())
125
+
126
+ # Wake event loop.
127
+ loop.call_soon_threadsafe(lambda: None)
128
+
129
+ signal.signal(signal.SIGQUIT, dump_all_tasks)
130
+
131
+ # Call _mixin_enter() on all mixed-in base classes.
132
+ for base in self.__class__.__bases__:
133
+ if enter := getattr(base, "_mixin_enter", None):
134
+ await enter(self, log)
135
+
136
+ failed = False
137
+ try:
138
+ async with asyncio.TaskGroup() as group:
139
+ async for request in requests(self.request_class()):
140
+ group.create_task(self.handle(log, request))
141
+
142
+ except ExceptionGroup as exc_group:
143
+ for exc in exc_group.exceptions:
144
+ if isinstance(exc, ValidationError):
145
+ if len(exc.errors) == 1:
146
+ log.error(exc.errors[0])
147
+ else:
148
+ log.error(
149
+ "Multiple validation errors:\n - "
150
+ + "\n - ".join(exc.errors)
151
+ )
152
+ failed = True
153
+ elif isinstance(exc, Stopped):
154
+ if exc.error:
155
+ log.error(f"{exc.error}")
156
+ failed = True
157
+ else:
158
+ log.error("".join(traceback.format_exception(exc)))
159
+ failed = True
160
+
161
+ finally:
162
+ # Call _mixin_exit() on all mixed-in base classes, in reverse order.
163
+ for base in reversed(self.__class__.__bases__):
164
+ if exit := getattr(base, "_mixin_exit", None):
165
+ await exit(self, log)
166
+
167
+ await self.exit(log)
168
+
169
+ # Restore the original signal handler
170
+ signal.signal(signal.SIGQUIT, original_sigquit)
171
+
172
+ if failed:
173
+ raise SystemExit(1)
174
+
175
+ async def exit(self, log: FlowLogger):
176
+ return # no-op default
@@ -0,0 +1,20 @@
1
+ """Fire-and-forget task spawning that keeps a strong reference.
2
+
3
+ asyncio only holds a weak reference to a running task, so a task whose only
4
+ reference is the `create_task()` return value can be garbage-collected
5
+ mid-flight. Callers here want to start work without awaiting it, so the
6
+ reference has to live somewhere until the task finishes.
7
+ """
8
+
9
+ import asyncio
10
+ from typing import Any, Coroutine
11
+
12
+ _tasks: set[asyncio.Task[Any]] = set()
13
+
14
+
15
+ def spawn(coro: Coroutine[Any, Any, Any], *, name: str | None = None) -> asyncio.Task[Any]:
16
+ """Start `coro` as a task which is not awaited, and keep it alive."""
17
+ task = asyncio.create_task(coro, name=name)
18
+ _tasks.add(task)
19
+ _ = task.add_done_callback(_tasks.discard)
20
+ return task
@@ -0,0 +1,112 @@
1
+ import asyncio
2
+ from collections.abc import AsyncGenerator
3
+ from typing import AsyncIterator, TypeVar, cast
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ async def merge(*iterators: AsyncGenerator[T, None]) -> AsyncIterator[T]:
9
+ """
10
+ Merge multiple async iterators into a single stream in completion order.
11
+
12
+ Items are yielded as they become available from any iterator, without
13
+ preserving the original ordering. All iterators run concurrently.
14
+
15
+ If any iterator raises an exception, all other iterators are cancelled
16
+ and the exception is propagated after cleanup. Similarly, if the consumer
17
+ stops iterating early (e.g., using break), all iterators are cancelled.
18
+
19
+ Args:
20
+ *iterators: Variable number of async iterators to merge. Can be
21
+ async generators or any object implementing AsyncIterator protocol.
22
+
23
+ Yields:
24
+ Items from any of the input iterators as they become available.
25
+
26
+ Raises:
27
+ Exception: Any exception raised by the input iterators is propagated
28
+ to the caller after all iterators are properly cleaned up.
29
+
30
+ Example:
31
+ >>> async def gen1():
32
+ ... for i in [1, 2, 3]:
33
+ ... yield i
34
+ ...
35
+ >>> async def gen2():
36
+ ... for i in [4, 5, 6]:
37
+ ... yield i
38
+ ...
39
+ >>> async for item in merge(gen1(), gen2()):
40
+ ... print(item) # Prints 1-6 in arbitrary order
41
+
42
+ Note:
43
+ The bounded queue (size 16) provides backpressure to prevent fast
44
+ iterators from consuming excessive memory while waiting for slow
45
+ consumers. This makes it safe to use with high-throughput streams.
46
+ """
47
+ if not iterators:
48
+ return
49
+
50
+ _DONE = object()
51
+ queue: asyncio.Queue[T | BaseException | object] = asyncio.Queue(16)
52
+ active_count = len(iterators)
53
+ cancelling = False
54
+
55
+ async def _drain(iterator: AsyncIterator[T]) -> None:
56
+ nonlocal cancelling
57
+ try:
58
+ async for item in iterator:
59
+ await queue.put(item)
60
+ except Exception as e:
61
+ if not cancelling:
62
+ cancelling = True
63
+ await queue.put(e)
64
+ finally:
65
+ await queue.put(_DONE)
66
+
67
+ tasks = [asyncio.create_task(_drain(aiter)) for aiter in iterators]
68
+
69
+ async def _reap() -> None:
70
+ """Discard queued items so blocked producers can finish unwinding."""
71
+ while True:
72
+ await queue.get()
73
+
74
+ def _cancel_all_tasks() -> None:
75
+ nonlocal cancelling
76
+ cancelling = True
77
+ for task in tasks:
78
+ if not task.done():
79
+ task.cancel()
80
+
81
+ try:
82
+ while active_count > 0:
83
+ item = await queue.get()
84
+
85
+ if isinstance(item, BaseException):
86
+ raise item
87
+
88
+ if item is _DONE:
89
+ active_count -= 1
90
+ else:
91
+ # Neither the _DONE sentinel nor an exception, so an item of T.
92
+ yield cast(T, item)
93
+
94
+ finally:
95
+ _cancel_all_tasks()
96
+
97
+ # A `_drain` task cancelled while blocked on a full queue still runs its
98
+ # own `finally`, which awaits `queue.put(_DONE)`. By now nothing is
99
+ # consuming the queue, so that put would never complete and the gather
100
+ # below would wait forever. Keep draining until the tasks have exited.
101
+ reaper = asyncio.create_task(_reap())
102
+ try:
103
+ await asyncio.gather(*tasks, return_exceptions=True)
104
+ finally:
105
+ reaper.cancel()
106
+ await asyncio.gather(reaper, return_exceptions=True)
107
+
108
+ for iterator in iterators:
109
+ try:
110
+ await iterator.aclose()
111
+ except Exception:
112
+ pass
@@ -0,0 +1,122 @@
1
+ import asyncio
2
+ from dataclasses import dataclass
3
+ from typing import AsyncGenerator, Awaitable, Generic, TypeVar
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ @dataclass
9
+ class BufferWork(Generic[T]):
10
+ aw: Awaitable[T]
11
+ result: asyncio.Queue[T | None]
12
+
13
+ next: asyncio.Future["BufferWork[T] | None"]
14
+
15
+
16
+ async def buffer_ordered(
17
+ aws: AsyncGenerator[Awaitable[T], None],
18
+ concurrency: int,
19
+ ) -> AsyncGenerator[T, None]:
20
+ """
21
+ Run the stream of awaitables 'aws' concurrently and return the results of
22
+ each in order. There may be up to 'concurrency' results from the awaitables
23
+ held in memory at a time, so a lower concurrency may improve memory usage at
24
+ the expense of throughput.
25
+
26
+ Args:
27
+ aws: A stream of awaitables to run concurrently. concurrency: The
28
+ maximum number of concurrent awaitables to run.
29
+
30
+ Returns:
31
+ A stream of results from the awaitables, with the order matching the
32
+ order yielded by 'aws'.
33
+ """
34
+
35
+ work: asyncio.Queue[BufferWork[T] | None] = asyncio.Queue(1)
36
+ next: asyncio.Future[BufferWork[T] | None] = asyncio.Future()
37
+
38
+ async def _producer():
39
+ current = next
40
+ try:
41
+ async for aw in aws:
42
+ this_next: asyncio.Future[BufferWork[T] | None] = asyncio.Future()
43
+ this_work = BufferWork(
44
+ aw=aw,
45
+ result=asyncio.Queue(1),
46
+ next=this_next,
47
+ )
48
+ current.set_result(this_work)
49
+ current = this_next
50
+ await work.put(this_work)
51
+
52
+ # Wait until the last item has been removed from the queue by a
53
+ # worker before requesting anything else from the awaitables
54
+ # generator. This prevents an awaitable from being held in limbo
55
+ # in this loop without being awaited if the producer or worker
56
+ # raises an exception and exits early.
57
+ await work.join()
58
+
59
+ # Send stopping signals to the output loop and workers.
60
+ current.set_result(None)
61
+ for _ in range(concurrency):
62
+ await work.put(None)
63
+ except Exception:
64
+ # Signal the output loop to stop.
65
+ current.set_result(None)
66
+ raise
67
+
68
+ async def _worker():
69
+ while True:
70
+ this_work = await work.get()
71
+ work.task_done() # Signal removal from the queue, per the note above in _producer.
72
+ if this_work is None:
73
+ break
74
+
75
+ try:
76
+ this_work.result.put_nowait(await this_work.aw)
77
+ except Exception:
78
+ # Signal the output loop to stop.
79
+ this_work.result.put_nowait(None)
80
+ raise
81
+ # Do not get another awaitable until this one has been fully handled
82
+ # by the output loop. This limits the number of pending work items
83
+ # to output, which is important their result may not necessarily be
84
+ # small and will be held in memory.
85
+ await this_work.result.join()
86
+
87
+ try:
88
+ async with asyncio.TaskGroup() as tg:
89
+ for coro in [_producer(), *[_worker() for _ in range(concurrency)]]:
90
+ tg.create_task(coro)
91
+
92
+ # Output loop.
93
+ while True:
94
+ finished_work = await next
95
+ if finished_work is None:
96
+ break
97
+
98
+ this_result = await finished_work.result.get()
99
+ if this_result is None:
100
+ break
101
+
102
+ yield this_result
103
+
104
+ # Signal the worker so it can start on another awaitable.
105
+ finished_work.result.task_done()
106
+ # Output the next result per the original ordering of input awaitables.
107
+ next = finished_work.next
108
+ except ExceptionGroup as eg:
109
+ # Raise the first error from the producer or any of the workers.
110
+ for e in eg.exceptions:
111
+ raise e
112
+ except Exception:
113
+ raise
114
+ finally:
115
+ # Await any queued awaitables, discarding further errors.
116
+ while not work.empty():
117
+ remaining_work = work.get_nowait()
118
+ if remaining_work is not None:
119
+ try:
120
+ await remaining_work.aw
121
+ except Exception:
122
+ pass
@@ -0,0 +1,9 @@
1
+ from .task import Request, Response, Task
2
+ from .base_capture_connector import BaseCaptureConnector
3
+
4
+ __all__ = [
5
+ "Request",
6
+ "Response",
7
+ "Task",
8
+ "BaseCaptureConnector",
9
+ ]
@@ -0,0 +1,26 @@
1
+ import asyncio
2
+ import shutil
3
+ from typing import IO, BinaryIO
4
+
5
+ # Global lock for serializing all emissions to stdout and prevent interleaving output.
6
+ _emit_lock = asyncio.Lock()
7
+
8
+
9
+ async def emit_bytes(data: bytes, output: BinaryIO) -> None:
10
+ async with _emit_lock:
11
+ await asyncio.to_thread(_write_bytes, data, output)
12
+
13
+
14
+ async def emit_from_buffer(buffer: IO[bytes], output: BinaryIO) -> None:
15
+ async with _emit_lock:
16
+ await asyncio.to_thread(_copy_buffer, buffer, output)
17
+
18
+
19
+ def _write_bytes(data: bytes, output: BinaryIO) -> None:
20
+ output.write(data)
21
+ output.flush()
22
+
23
+
24
+ def _copy_buffer(buffer: IO[bytes], output: BinaryIO) -> None:
25
+ shutil.copyfileobj(buffer, output)
26
+ output.flush()