conduit-connector-sdk 0.1.0.dev1__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.
Files changed (41) hide show
  1. conduit/__init__.py +40 -0
  2. conduit/_batch.py +357 -0
  3. conduit/_build.py +394 -0
  4. conduit/_cli.py +85 -0
  5. conduit/_dispatch.py +80 -0
  6. conduit/_grpc/__init__.py +46 -0
  7. conduit/_grpc/_controller.py +83 -0
  8. conduit/_grpc/adapters.py +188 -0
  9. conduit/_grpc/config/v1/parameter_pb2.py +43 -0
  10. conduit/_grpc/config/v1/parameter_pb2.pyi +59 -0
  11. conduit/_grpc/config/v1/parameter_pb2_grpc.py +4 -0
  12. conduit/_grpc/connector/v2/destination_pb2.py +113 -0
  13. conduit/_grpc/connector/v2/destination_pb2.pyi +145 -0
  14. conduit/_grpc/connector/v2/destination_pb2_grpc.py +418 -0
  15. conduit/_grpc/connector/v2/source_pb2.py +111 -0
  16. conduit/_grpc/connector/v2/source_pb2.pyi +140 -0
  17. conduit/_grpc/connector/v2/source_pb2_grpc.py +428 -0
  18. conduit/_grpc/connector/v2/specifier_pb2.py +57 -0
  19. conduit/_grpc/connector/v2/specifier_pb2.pyi +55 -0
  20. conduit/_grpc/connector/v2/specifier_pb2_grpc.py +81 -0
  21. conduit/_grpc/opencdc/v1/opencdc_pb2.py +49 -0
  22. conduit/_grpc/opencdc/v1/opencdc_pb2.pyi +89 -0
  23. conduit/_grpc/opencdc/v1/opencdc_pb2_grpc.py +4 -0
  24. conduit/_handshake.py +222 -0
  25. conduit/_introspect.py +49 -0
  26. conduit/config.py +551 -0
  27. conduit/destination.py +435 -0
  28. conduit/errors.py +412 -0
  29. conduit/record.py +274 -0
  30. conduit/schema.py +176 -0
  31. conduit/serve.py +623 -0
  32. conduit/source.py +446 -0
  33. conduit/testing/README.md +16 -0
  34. conduit/testing/__init__.py +24 -0
  35. conduit/testing/acceptance.py +446 -0
  36. conduit/testing/fixtures.py +142 -0
  37. conduit_connector_sdk-0.1.0.dev1.dist-info/METADATA +245 -0
  38. conduit_connector_sdk-0.1.0.dev1.dist-info/RECORD +41 -0
  39. conduit_connector_sdk-0.1.0.dev1.dist-info/WHEEL +4 -0
  40. conduit_connector_sdk-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  41. conduit_connector_sdk-0.1.0.dev1.dist-info/licenses/LICENSE +201 -0
conduit/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """Python SDK for building Conduit source and destination connectors.
2
+
3
+ Public author-facing surface, per
4
+ ``docs/design/20260707-python-connector-sdk.md`` §2/§3: ``Source``,
5
+ ``Destination``, ``Record``, ``Change``, ``Operation``, ``BaseConfig``,
6
+ ``Field``, ``Specification``, ``serve``, and the connector-facing
7
+ exceptions (``BackoffRetry``, ``BatchWriteError``, ``ConnectorError``).
8
+
9
+ See ``CONTRIBUTING.md`` for the Tier-1 review bar this package is held to.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from conduit.config import BaseConfig, Field, Specification, to_parameters
15
+ from conduit.destination import Destination
16
+ from conduit.errors import BackoffRetry, BatchWriteError, ConnectorError
17
+ from conduit.record import Change, Data, Metadata, Operation, Record
18
+ from conduit.serve import serve
19
+ from conduit.source import Source
20
+
21
+ __version__ = "0.1.0.dev0"
22
+
23
+ __all__ = [
24
+ "BackoffRetry",
25
+ "BaseConfig",
26
+ "BatchWriteError",
27
+ "Change",
28
+ "ConnectorError",
29
+ "Data",
30
+ "Destination",
31
+ "Field",
32
+ "Metadata",
33
+ "Operation",
34
+ "Record",
35
+ "Source",
36
+ "Specification",
37
+ "__version__",
38
+ "serve",
39
+ "to_parameters",
40
+ ]
conduit/_batch.py ADDED
@@ -0,0 +1,357 @@
1
+ """SDK-level batching middleware: ``sdk.batch.size`` / ``sdk.batch.delay``.
2
+
3
+ Mirrors the Go SDK's batching contract exactly where the two protocols'
4
+ shapes allow, and documents every deliberate divergence at the exact point
5
+ it happens. The Go-side references this module reproduces:
6
+
7
+ - ``internal/batcher.go`` (``conduit-connector-sdk``): the generic
8
+ size/delay-triggered batch accumulator both ``Destination``/``Source``
9
+ batching build on.
10
+ - ``destination_middleware.go``'s ``DestinationWithBatch``/
11
+ ``destinationWithBatch``/``writeStrategyBatch``: destination-side wiring
12
+ -- ``sdk.batch.size``/``sdk.batch.delay`` are injected config keys (not
13
+ authored by the connector), and batching only activates when
14
+ ``BatchSize > 1 || BatchDelay > 0`` (``destination.go:182``) -- a
15
+ ``size`` of 0 *or* 1 with no delay is passthrough: no accumulation, no
16
+ background task, one wire message in for one wire message out.
17
+ - ``source_middleware.go``'s ``SourceWithBatch``/``sourceWithBatch``: the
18
+ same two keys on the source side, with the identical activation
19
+ threshold (``source_middleware.go:709``) and the same "no read-ahead
20
+ task at all when batching is off" property this module's
21
+ :func:`collect_batches` preserves (callers only invoke it when
22
+ :attr:`BatchConfig.enabled` is true; the disabled case never even
23
+ imports this module's event loop machinery into the request path -- see
24
+ ``conduit.source``/``conduit.destination`` for the branch point).
25
+
26
+ **Deliberate divergence from Go, documented here rather than silently:**
27
+ the Go SDK's real advantage on the source side is an optional
28
+ ``ReadN(ctx, n) ([]Record, error)`` a connector can implement for a
29
+ genuinely batched read (``collectWithReadN``); absent that override, Go
30
+ itself falls back to the single-record ``collectWithRead`` path this
31
+ module's :func:`collect_batches` is modeled on. This SDK's author-facing
32
+ ``Source`` ABC has no ``read_batch``/``ReadN`` override point at all yet
33
+ (explicitly deferred past v0.19 core, see
34
+ ``docs/design/20260707-python-connector-sdk.md`` §2.6) -- so every Python
35
+ connector using ``sdk.batch.*`` today gets Go's *fallback* behavior
36
+ (buffer individual ``read()`` calls into batches), never Go's optimized
37
+ path. This is a real, current capability gap versus Go, not an oversight:
38
+ adding ``read_batch`` is tracked as its own follow-up, at which point
39
+ :func:`collect_batches` would gain a second, ``ReadN``-shaped caller the
40
+ same way Go's ``collectWithReadN`` sits alongside ``collectWithRead``.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import asyncio
46
+ import datetime
47
+ from collections.abc import AsyncIterator, Mapping
48
+ from dataclasses import dataclass
49
+ from typing import TypeVar
50
+
51
+ import conduit._grpc # noqa: F401 -- sets up sys.path, see conduit._grpc.__init__
52
+ from conduit.config import parse_go_duration
53
+ from conduit.errors import (
54
+ INVALID_BATCH_DELAY_CODE,
55
+ INVALID_BATCH_SIZE_CODE,
56
+ ConfigFieldError,
57
+ ConfigValidationError,
58
+ )
59
+ from config.v1 import parameter_pb2 as _parameter_pb2
60
+
61
+ T = TypeVar("T")
62
+
63
+ BATCH_SIZE_KEY = "sdk.batch.size"
64
+ """Wire config key -- matches Go's `json:"sdk.batch.size"` struct tag exactly."""
65
+
66
+ BATCH_DELAY_KEY = "sdk.batch.delay"
67
+ """Wire config key -- matches Go's `json:"sdk.batch.delay"` struct tag exactly."""
68
+
69
+ _ZERO_DELAY = datetime.timedelta(0)
70
+
71
+
72
+ @dataclass(slots=True, frozen=True)
73
+ class BatchConfig:
74
+ """Parsed ``sdk.batch.size``/``sdk.batch.delay`` for one connector instance.
75
+
76
+ Attributes:
77
+ size: maximum number of items per batch before it's flushed early.
78
+ ``0`` means "no size limit" (Go's default, ``validate:"gt=-1"``
79
+ i.e. any non-negative value is legal) -- a batch then only
80
+ flushes on the delay timer, which can produce arbitrarily
81
+ large batches for a fast source/high-throughput destination if
82
+ ``delay`` is set without ``size`` (Go warns about exactly this
83
+ combination at ``Open`` time on both the source and
84
+ destination side; this SDK does not currently have an
85
+ established logging channel to reproduce that warning through
86
+ -- see the module docstring's divergence note -- so it is
87
+ documented here instead).
88
+ delay: maximum time an incomplete batch waits before flushing.
89
+ ``timedelta(0)`` means "no delay limit" -- a batch then only
90
+ flushes once ``size`` items have accumulated, which can wait
91
+ indefinitely for a slow/low-throughput source if ``size`` is
92
+ set without ``delay`` (the same Go warning, mirrored the same
93
+ way).
94
+ """
95
+
96
+ size: int = 0
97
+ delay: datetime.timedelta = _ZERO_DELAY
98
+
99
+ @property
100
+ def enabled(self) -> bool:
101
+ """Whether batching is actually active for this config.
102
+
103
+ The exact Go activation threshold, reproduced verbatim rather than
104
+ rounded to a more "intuitive" ``size >= 1``:
105
+ ``destination.go:182``'s ``batchConfig.BatchSize > 1 ||
106
+ batchConfig.BatchDelay > 0`` and ``source_middleware.go:709``'s
107
+ identical condition. A ``size`` of exactly 1 with no delay is
108
+ passthrough, same as ``size == 0`` -- a "batch" of one record is
109
+ never observably different from no batching, so Go doesn't pay for
110
+ the accumulator machinery in that case, and neither does this SDK.
111
+ """
112
+ return self.size > 1 or self.delay > _ZERO_DELAY
113
+
114
+
115
+ def sdk_batch_parameters() -> dict[str, _parameter_pb2.Parameter]:
116
+ """The two ``sdk.batch.*`` parameters every connector gets for free.
117
+
118
+ Mirrors Go's ``DestinationWithBatch``/``SourceWithBatch`` struct tags
119
+ (``json:"sdk.batch.size" default:"0" validate:"gt=-1"`` /
120
+ ``json:"sdk.batch.delay" default:"0"``), which Go's ``paramgen`` merges
121
+ into *every* connector's ``Specify`` parameter map automatically via
122
+ ``DefaultDestinationMiddleware``/``DefaultSourceMiddleware`` embedding
123
+ -- a Go connector author never has to declare these fields themselves,
124
+ and Conduit's own tooling (CLI, UI) expects them present in every
125
+ connector's advertised parameters, not just ones that happen to
126
+ declare matching fields on their own config struct.
127
+
128
+ ``conduit.serve`` merges this into both ``source_params``/
129
+ ``destination_params`` for every registered connector, matching that
130
+ behavior -- see ``conduit.serve._build_plugin_server``.
131
+
132
+ Returns:
133
+ A mapping with exactly the two keys :data:`BATCH_SIZE_KEY`/
134
+ :data:`BATCH_DELAY_KEY`.
135
+ """
136
+ return {
137
+ BATCH_SIZE_KEY: _parameter_pb2.Parameter(
138
+ default="0",
139
+ description="Maximum size of batch before it gets written/read.",
140
+ type=_parameter_pb2.Parameter.TYPE_INT,
141
+ validations=[
142
+ _parameter_pb2.Validation(
143
+ type=_parameter_pb2.Validation.TYPE_GREATER_THAN, value="-1"
144
+ )
145
+ ],
146
+ ),
147
+ BATCH_DELAY_KEY: _parameter_pb2.Parameter(
148
+ default="0s",
149
+ description="Maximum delay before an incomplete batch is written/read.",
150
+ type=_parameter_pb2.Parameter.TYPE_DURATION,
151
+ ),
152
+ }
153
+
154
+
155
+ def extract_batch_config(config: Mapping[str, str]) -> tuple[BatchConfig, dict[str, str]]:
156
+ """Pull ``sdk.batch.size``/``sdk.batch.delay`` out of a raw ``Configure`` config map.
157
+
158
+ Called by ``conduit.source._SourceServicer.Configure``/
159
+ ``conduit.destination._DestinationServicer.Configure`` *before* the
160
+ remaining map is handed to the connector's own
161
+ :class:`~conduit.config.BaseConfig` subclass for
162
+ ``model_validate`` -- these two keys are SDK-owned, never fields the
163
+ connector author declares themselves (matching Go: they live on
164
+ ``DestinationWithBatch``/``SourceWithBatch``, not the connector's own
165
+ config struct).
166
+
167
+ Args:
168
+ config: the raw ``map<string, string>`` from the ``Configure`` RPC.
169
+
170
+ Returns:
171
+ A ``(BatchConfig, remaining)`` pair: the parsed batch config, and a
172
+ **copy** of ``config`` with the two batch keys removed, ready to
173
+ pass to the connector's own config class.
174
+
175
+ Raises:
176
+ ConfigValidationError: if either value is present and malformed --
177
+ ``sdk.batch.size`` must parse as a non-negative integer;
178
+ ``sdk.batch.delay`` must parse as a non-negative
179
+ Go-duration-syntax string (:func:`conduit.config.parse_go_duration`).
180
+ Both fields are checked (not fail-fast on the first bad one),
181
+ matching :class:`~conduit.errors.ConfigValidationError`'s
182
+ "exhaustive, not one-at-a-time" design elsewhere in this SDK.
183
+ """
184
+ remaining = dict(config)
185
+ size_raw = remaining.pop(BATCH_SIZE_KEY, None)
186
+ delay_raw = remaining.pop(BATCH_DELAY_KEY, None)
187
+
188
+ errors: list[ConfigFieldError] = []
189
+
190
+ size = 0
191
+ if size_raw: # narrows out `None` and `""` -- see the two ValueError branches below
192
+ try:
193
+ size = int(size_raw)
194
+ if size < 0:
195
+ raise ValueError("must be >= 0")
196
+ except ValueError:
197
+ errors.append(
198
+ ConfigFieldError(
199
+ field=BATCH_SIZE_KEY,
200
+ code=INVALID_BATCH_SIZE_CODE,
201
+ message=(
202
+ f"{BATCH_SIZE_KEY!r} must be a non-negative integer, got {size_raw!r}"
203
+ ),
204
+ )
205
+ )
206
+
207
+ delay = _ZERO_DELAY
208
+ if delay_raw: # narrows out `None` and `""`, same as the size branch above
209
+ try:
210
+ delay = parse_go_duration(delay_raw)
211
+ if delay < _ZERO_DELAY:
212
+ raise ValueError("must be >= 0")
213
+ except ValueError:
214
+ errors.append(
215
+ ConfigFieldError(
216
+ field=BATCH_DELAY_KEY,
217
+ code=INVALID_BATCH_DELAY_CODE,
218
+ message=(
219
+ f"{BATCH_DELAY_KEY!r} must be a non-negative Go-duration-syntax "
220
+ f"string (e.g. '5s', '1h30m'), got {delay_raw!r}"
221
+ ),
222
+ )
223
+ )
224
+
225
+ if errors:
226
+ raise ConfigValidationError(errors)
227
+
228
+ return BatchConfig(size=size, delay=delay), remaining
229
+
230
+
231
+ _STOP = object()
232
+ """Sentinel distinguishing "the source iterator ended" from a real item of type ``T``."""
233
+
234
+
235
+ async def collect_batches(
236
+ items: AsyncIterator[T],
237
+ *,
238
+ config: BatchConfig,
239
+ ) -> AsyncIterator[list[T]]:
240
+ """Group items from ``items`` into ``config``-bounded batches.
241
+
242
+ Shared by both batching call sites -- the destination write path
243
+ (``conduit.destination._DestinationServicer``, over a per-record stream
244
+ flattened from incoming ``Run`` request batches) and the source
245
+ read-ahead path (``conduit.source._SourceServicer``, directly over the
246
+ existing backoff-aware ``_read_loop()``) -- because both reduce to the
247
+ same shape once the per-item stream exists: accumulate until ``size``
248
+ items are buffered or ``delay`` has elapsed since the first buffered
249
+ item, whichever comes first, exactly Go's ``select``-over-(channel-recv,
250
+ timer) shape in ``sourceWithBatch.collectWithRead``/
251
+ ``internal.Batcher.Enqueue``. ``asyncio.wait`` over two tasks --
252
+ "the next item" and "the delay timer" -- is this module's equivalent of
253
+ that ``select``.
254
+
255
+ Only called when ``config.enabled`` is true; callers keep the exact
256
+ passthrough branch (no task, no timer, one item in for one batch of one
257
+ item out... actually one item in for one *record* out, unbatched) in
258
+ their own code, matching the Go SDK's own ``if BatchSize > 1 ||
259
+ BatchDelay > 0`` branch point (``destination.go:182``,
260
+ ``source_middleware.go:709``) rather than hiding it inside this
261
+ function.
262
+
263
+ **Flush-on-end, not flush-in-``finally``:** when ``items`` ends
264
+ (``anext`` yields the internal stop sentinel), any non-empty buffered
265
+ remainder is yielded once more before this generator returns --
266
+ matching Go's ``Stop()``-triggered ``Flush()``
267
+ (``destination.go:271``): batching must never silently drop
268
+ buffered-but-unflushed items on a controlled stream end, per invariant
269
+ 3 (at-least-once is the floor). This flush happens in the main
270
+ ``try`` body's own control flow, deliberately **not** in a ``finally``
271
+ block: an async generator's ``finally`` also runs on ``GeneratorExit``
272
+ (e.g. if the caller's own enclosing ``async for`` is abandoned early
273
+ due to cancellation), and ``yield``ing there would raise
274
+ ``RuntimeError: async generator ignored GeneratorExit``. This is a
275
+ real, accepted narrow-window limitation, not an oversight: an
276
+ externally-cancelled ``Run`` (e.g. the whole RPC torn down by the
277
+ framework, not a normal drain) can lose a buffered-but-unflushed
278
+ remainder the same way an in-flight, never-acked write is already an
279
+ accepted loss elsewhere in this SDK on a hard stop (see
280
+ ``conduit.destination._DestinationServicer.drain``'s own "unavoidable,
281
+ narrow window" note for the same class of tradeoff). The controlled
282
+ shutdown path -- ``drain()`` setting the stop event, which is what
283
+ normally ends ``items`` -- goes through the flush-then-return path
284
+ above, not this one; that's the path invariant 7 (graceful shutdown by
285
+ default) actually depends on.
286
+
287
+ Args:
288
+ items: the per-item async stream to batch. Ending it (raising
289
+ ``StopAsyncIteration``) is this function's only normal
290
+ termination signal -- it does not itself watch any stop/cancel
291
+ event; callers arrange for ``items`` to end when they want
292
+ batching to stop (see ``conduit.source``/``conduit.destination``
293
+ for how each wires that through their own ``_stop_event``).
294
+ config: the batch thresholds. Caller-checked ``.enabled`` (see
295
+ above) -- this function does not re-check it.
296
+
297
+ Yields:
298
+ Non-empty lists of ``T``, each a complete batch (either
299
+ size-triggered, delay-triggered, or the final end-of-stream
300
+ remainder).
301
+ """
302
+ buffer: list[T] = []
303
+ delay_task: asyncio.Task[None] | None = None
304
+ next_task: asyncio.Task[T | object] | None = None
305
+
306
+ try:
307
+ while True:
308
+ if next_task is None:
309
+ next_task = asyncio.ensure_future(anext(items, _STOP))
310
+ waiters: set[asyncio.Task[object]] = {next_task}
311
+ if delay_task is not None:
312
+ waiters.add(delay_task)
313
+
314
+ await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED)
315
+
316
+ if delay_task is not None and delay_task.done():
317
+ delay_task = None
318
+ if buffer:
319
+ yield buffer
320
+ buffer = []
321
+ # `next_task` may or may not be done yet -- loop back and
322
+ # `asyncio.wait` on whatever's still pending; a task that's
323
+ # already done resolves immediately on the next `wait`.
324
+ continue
325
+
326
+ if next_task.done():
327
+ item = next_task.result()
328
+ next_task = None
329
+ if item is _STOP:
330
+ if buffer:
331
+ yield buffer
332
+ return
333
+ buffer.append(item) # type: ignore[arg-type] # narrowed: item is not _STOP
334
+ if config.size > 0 and len(buffer) >= config.size:
335
+ if delay_task is not None:
336
+ delay_task.cancel()
337
+ delay_task = None
338
+ yield buffer
339
+ buffer = []
340
+ elif config.delay > _ZERO_DELAY and delay_task is None:
341
+ delay_task = asyncio.ensure_future(asyncio.sleep(config.delay.total_seconds()))
342
+ finally:
343
+ # Cleanup only -- deliberately no `yield` here, see docstring.
344
+ if next_task is not None and not next_task.done():
345
+ next_task.cancel()
346
+ if delay_task is not None and not delay_task.done():
347
+ delay_task.cancel()
348
+
349
+
350
+ __all__ = [
351
+ "BATCH_DELAY_KEY",
352
+ "BATCH_SIZE_KEY",
353
+ "BatchConfig",
354
+ "collect_batches",
355
+ "extract_batch_config",
356
+ "sdk_batch_parameters",
357
+ ]