Python client library for defining and running Conduit
pipelines in code, over Conduit's existing control-plane gRPC API
(proto/api/v1/api.proto). No changes to Conduit itself are required.
Status: pre-alpha, Slice 1. Implements
docs/design/20260724-embed-grpc-client-libraries.md's "Build slices -- Slice 1": Case A (named connector plugins) only. No release has shipped; the public API (Pipeline,local,connect,Client,Run) is not stable until it ships. See "Open questions for DeVaris" below.
- A builder for pipeline configs (
Pipeline(id).source(...).destination(...).process(...)) that produces the exact request payloads Conduit'sPipelineService/ConnectorService/ProcessorServiceRPCs expect -- no YAML, no shelling out to theconduitCLI. - A sync gRPC client (
conduit.connect(addr)) for an already-running, independently deployed Conduit engine -- the production shape. - A local-engine supervisor (
conduit.local(...)) that downloads a pinnedconduitrelease binary on first use, spawns it with its API enabled on loopback, and hands you a client bound to it -- for dev, notebooks, and one-off jobs. Not a production story -- see "Deployment modes" below. - Errors are always
conduit.ConduitError(a stable code, message, and optional config path/suggestion) -- never a rawgrpc.RpcErroror traceback.
Why gRPC and not a C-ABI/FFI shared library: see
docs/design/20260724-embed-bindings-via-grpc.md
(the ADR) -- in short, the hot record-processing path never crosses the
embedding boundary, only low-frequency lifecycle/status calls do, so gRPC's
cost (a loopback network hop on those calls) is real but small, and it comes
with a working story for driving an already-deployed remote engine that a
C-ABI structurally cannot have.
import conduit
pipeline = (
conduit.Pipeline("orders-sync")
.source("generator", settings={"format.type": "structured"}, operations="create")
.destination("log", level="info")
)
with conduit.local(state_dir="./conduit-state") as client:
run = client.run(pipeline)
run.wait_running()
print(run.status())
run.stop()First run downloads a pinned conduit release binary (see "Binary
provisioning" below); later runs reuse the cached copy. state_dir is
required to be a real, reused directory for anything beyond a one-off
experiment -- see "Deployment modes."
- Python 3.11+
uvfor dependency management (recommended;pip install -e .[dev]also works)- Network access on first
conduit.local()call per version (binary download), unless you passbinary=to point at an already-present executable.
src/conduit/
__init__.py # public API surface
pipeline.py # Pipeline builder -> BuildPlan (pure, no I/O)
client.py # Client (gRPC stubs), connect()
run.py # Run handle, RunStatus
errors.py # ConduitError, grpc.RpcError -> ConduitError translation
_local.py # local(): binary provisioning + subprocess supervision
_provision.py # download-on-first-use, checksum-verified binary provisioning
_grpc/ # generated protobuf/grpc stubs (buf generate output)
docs/design/ # design doc + ADR this repo implements (copied from ConduitIO/conduit)
tests/unit/ # builder/error/provisioning unit tests (no network, no subprocess)
tests/integration/ # spins up a real conduit via local() -- see "Testing" below
conduit.Pipeline(id, *, name=None, description="")-- fluent builder..source(plugin, *, name="", settings=None, **kwargs),.destination(...)(same shape),.process(plugin, *, condition="", workers=None, settings=None, **kwargs)(pipeline-level; per-connector processor attachment is a natural follow-up, not yet exposed),.dlq(plugin="builtin:log", *, window_size=None, window_nack_threshold=None, settings=None, **kwargs). Config values are coerced to strings --Connector.Config.settings/Processor.Config.settingsare a flatmap<string, string>on the wire today. Real connector config keys are often not valid Python identifiers (e.g. the builtin generator connector'sformat.type,sdk.batch.size) -- use the explicitsettings={"format.type": "structured"}dict for those; plain identifier-shaped keys can use**kwargs(operations="create") instead, and both can be combined in the same call. Typed per-connector config (postgres.Source(url=..., tables=[...])with real parameter types, generated from each plugin's param spec) is the documented fast-follow, not hand-written per connector.conduit.local(state_dir=None, *, version=None, binary=None, startup_timeout=30.0)-- provisions (if needed), spawns, and waits for aconduitsubprocess; returns a client-likeLocalConduit(context manager, or call.close()yourself).conduit.connect(addr, *, credentials=None, check_version=True, timeout=5.0)-- dials an already-running engine; returns aClient.client.run(pipeline, *, start=True, timeout=10.0) -> Run-- creates the pipeline, its connectors and processors, applies the DLQ if configured, and starts it (unlessstart=False).run.wait_running(timeout=30.0, poll_interval=0.2),run.status()(->RunStatus:status,error,stopped_reason,is_running,is_degraded),run.stop(force=False, timeout=10.0).
Exactly the incremental CRUD-plus-lifecycle RPCs in proto/api/v1/api.proto
(not PlanPipeline/ApplyPipeline's whole-document shape, which is a
different, YAML-provisioner-aligned surface):
PipelineService:CreatePipeline,GetPipeline,StartPipeline,StopPipeline,UpdateDLQ.ConnectorService:CreateConnector.ProcessorService:CreateProcessor.InformationService:GetInfo(engine version check at connect-time).
UpdatePipeline/DeletePipeline/UpdateConnector/DeleteConnector/
UpdateProcessor/DeleteProcessor/ListPipelines/etc. are defined on the
generated stubs (nothing stops a caller reaching for Client._pipelines
directly) but have no builder-level convenience yet -- Slice 1 scope is
"define + run," not full lifecycle management.
- Mode 1,
conduit.local(): engine co-located with your process, tied to its lifecycle. Fits dev, notebooks, one-off jobs. Never sell this as a production pipeline story -- no independent lifecycle, no restart-without-the-host-restarting, no fleet management.state_dirdefaults to./.conduit/state(stable across repeated runs in the same directory) rather than an ephemeral temp dir, but a host-process crash still takes the engine down with it. - Mode 2,
conduit.connect(addr): a deployed, long-running Conduit service, managed independently (systemd, Kubernetes, ...). This is what a production pipeline should use.
inline_source/inline_destination (Case B, driving a host-implemented
Python connector via a new engine-side "external connector" feature) is
Slice 2, not built here -- it needs its own engine-side design-doc sign-off
pass per the design doc (Tier 1, touches connector acquisition).
conduit.local() never looks at PATH. It downloads a specific,
version-pinned GoReleaser release asset
(conduit_<version>_<Os>_<Arch>.tar.gz, .zip on Windows) from
GitHub Releases, verifies its
SHA-256 against the release's published checksums.txt, extracts the
conduit binary, and caches it under a per-version directory in your user
cache dir (platformdirs.user_cache_dir("conduit-client-python")). A second
call with the same version reuses the cache with no network access. Pass
binary= to point at an already-present executable instead (skips
provisioning entirely) -- useful for CI images that pre-bake a specific build.
The default version is pinned in conduit._provision.DEFAULT_CONDUIT_VERSION
(currently the latest stable release at the time this was written); override
with local(version="0.19.0") or the CONDUIT_CLIENT_ENGINE_VERSION env var.
uv sync --all-extras
uv run pytest -v -m "not integration" # unit tests: no network, no subprocess
uv run pytest -v -m integration # spins up a real conduit binary end-to-endThe integration test (tests/integration/test_local_generator_log.py) runs
conduit.local() for real and drives a generator -> log pipeline through
run()/wait_running()/stop(). It downloads a real release binary on
first use -- if the sandbox running these tests has no network access, it
is honestly marked skip with the reason stated (see the test file), never
faked or mocked into looking like a pass.
See the design doc's "Open questions for DeVaris" for the full list; the ones this repo's existence doesn't yet resolve:
- PyPI distribution name. This repo uses the working name
conduit-clientinpyproject.toml-- not settled againstconduit-embed/bareconduit. - Release target (v0.19 fast-follow vs. v0.20 anchor).
- Binary provisioning here is download-on-first-use with checksum verification (not a bundled-per-platform wheel) -- lighter package, needs network + GitHub Releases availability. Confirms the design doc's open question 3 in the "lighter, needs network" direction; flag if that's wrong for the intended distribution story.