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.
- estuary_cdk/__init__.py +176 -0
- estuary_cdk/_background.py +20 -0
- estuary_cdk/async_streams.py +112 -0
- estuary_cdk/buffer_ordered.py +122 -0
- estuary_cdk/capture/__init__.py +9 -0
- estuary_cdk/capture/_emit.py +26 -0
- estuary_cdk/capture/base_capture_connector.py +391 -0
- estuary_cdk/capture/common.py +1424 -0
- estuary_cdk/capture/connector_status.py +73 -0
- estuary_cdk/capture/document.py +57 -0
- estuary_cdk/capture/request.py +69 -0
- estuary_cdk/capture/response.py +51 -0
- estuary_cdk/capture/task.py +509 -0
- estuary_cdk/capture/transactor.py +77 -0
- estuary_cdk/capture/webhook/__init__.py +0 -0
- estuary_cdk/capture/webhook/match.py +346 -0
- estuary_cdk/capture/webhook/resources.py +145 -0
- estuary_cdk/capture/webhook/server.py +215 -0
- estuary_cdk/cron.py +31 -0
- estuary_cdk/emitted_changes_cache.py +70 -0
- estuary_cdk/feature_flags.py +134 -0
- estuary_cdk/flow.py +264 -0
- estuary_cdk/gunzip_stream.py +35 -0
- estuary_cdk/http.py +697 -0
- estuary_cdk/incremental_csv_processor.py +368 -0
- estuary_cdk/incremental_json_processor.py +219 -0
- estuary_cdk/logger.py +115 -0
- estuary_cdk/py.typed +0 -0
- estuary_cdk/shim_airbyte_cdk.py +539 -0
- estuary_cdk/unzip_stream.py +52 -0
- estuary_cdk/utils.py +161 -0
- estuary_cdk-0.3.0.dev31.dist-info/METADATA +18 -0
- estuary_cdk-0.3.0.dev31.dist-info/RECORD +34 -0
- estuary_cdk-0.3.0.dev31.dist-info/WHEEL +4 -0
estuary_cdk/__init__.py
ADDED
|
@@ -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,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()
|