Packaging Python Workflows
This guide explains how to turn a Python workflow into a .cloacina package that
can be deployed to the Cloacina daemon or server without shipping your source
environment.
One canonical format. A
.cloacinapackage is described by a top-levelpackage.tomland a Python module tree under aworkflow/directory. This is the only format the server/daemon accept. (Older docs that usedpyproject.toml+[tool.cloaca]+ a hand-writtenmanifest.jsonare obsolete.)
Prerequisites
Section titled “Prerequisites”- A working Python workflow (see Python tutorials)
cloacainstalled (pip install cloaca)- Understanding of the daemon or server deployment model
Project Layout
Section titled “Project Layout”The package root contains a package.toml and a workflow/ directory holding
your module tree:
data-pipeline/├── package.toml # package + metadata (REQUIRED, at the root)└── workflow/ # REQUIRED directory — your modules live here └── data_pipeline/ # your entry module package ├── __init__.py └── tasks.py # @cloaca.task decorators hereKey requirements:
- The module tree must live under
workflow/. A top-level module (e.g.data-pipeline/data_pipeline/) fails to load withMissing workflow source directory. entry_moduleinpackage.tomlis the dotted path relative toworkflow/(here,data_pipeline.tasks).- Tasks/triggers must register at import time (module level) with bare
@cloaca.task/@cloaca.triggerdecorators — do not wrap them in aWorkflowBuildercontext. The loader establishes the workflow context (fromworkflow_name) before importing your module; aWorkflowBuilderhere would shadow it and the package would load with zero tasks. (WorkflowBuilderis only for the in-process test path in Step 4.)
Step 1: Write package.toml
Section titled “Step 1: Write package.toml”[package]name = "data-pipeline"version = "1.0.0"interface = "cloacina-workflow-plugin"interface_version = 1extension = "cloacina"
[metadata]language = "python"workflow_name = "data_pipeline"entry_module = "data_pipeline.tasks"description = "ETL pipeline for analytics"author = "you@example.com"requires_python = ">=3.10"The [metadata] table is a closed schema — unknown keys are rejected at
upload. The accepted keys are:
| Field | Required | Description |
|---|---|---|
language |
yes | "python" (or "rust"). |
workflow_name |
for workflows | The WorkflowBuilder(name=...) value. |
graph_name |
for computation graphs | The graph name (instead of workflow_name). |
entry_module |
yes | Dotted module path relative to workflow/ that the loader imports. |
description |
no | Human-readable description. |
author |
no | Author/owner. |
requires_python |
no | e.g. ">=3.11". |
reaction_mode / input_strategy |
computation graphs | when_any/when_all, latest/sequential. |
accumulators |
computation graphs | [[metadata.accumulators]] entries (stream/kafka sources). |
Do not add a
package_typekey or[[metadata.triggers]]table — both are rejected by the parser. Triggers are declared in code via@cloaca.trigger, not in the manifest.
Step 2: Write Your Workflow
Section titled “Step 2: Write Your Workflow”In your entry module (workflow/data_pipeline/tasks.py), declare tasks with
bare @cloaca.task decorators at module level — no WorkflowBuilder:
import cloaca
@cloaca.task(dependencies=[])def extract(context): # cloaca.var() reads from CLOACINA_VAR_ env vars at runtime # See "External Configuration" section below source = cloaca.var("DATA_SOURCE") context.set("raw_data", fetch_from(source)) # Replace with your data function return context
@cloaca.task(dependencies=["extract"])def transform(context): raw = context.get("raw_data") context.set("clean_data", clean(raw)) # Replace with your transform logic return context
@cloaca.task(dependencies=["transform"])def load(context): dest = cloaca.var("WAREHOUSE_URL") write_to(dest, context.get("clean_data")) # Replace with your load logic return contextThe tasks are grouped into a workflow by the workflow_name you set in
package.toml — the loader establishes that workflow context before importing
entry_module, and your bare decorators register into it. Make
workflow/data_pipeline/__init__.py import the entry module (so importing the
package registers the tasks), or point entry_module directly at the file that
defines them (data_pipeline.tasks, as above).
Step 3: Vendoring Dependencies
Section titled “Step 3: Vendoring Dependencies”If your workflow uses third-party libraries not available on the target host,
place them in a vendor/ directory at the package root. The loader adds both the
workflow/ directory and vendor/ to sys.path before importing.
data-pipeline/├── package.toml├── workflow/│ └── data_pipeline/│ └── tasks.py└── vendor/ └── requests/ └── __init__.pyStep 4: Test Before Packaging
Section titled “Step 4: Test Before Packaging”To run a bare-decorator module in-process, supply the workflow context the
packaged loader would normally provide by wrapping the import in a
WorkflowBuilder with the same name as your workflow_name:
import cloaca
# WorkflowBuilder here stands in for the loader's context (in-process only —# it is NOT part of the packaged module).with cloaca.WorkflowBuilder("data_pipeline"): import data_pipeline.tasks # bare @cloaca.task decorators register here
runner = cloaca.DefaultRunner(":memory:")try: result = runner.execute("data_pipeline", cloaca.Context()) assert result.status == "completed"finally: runner.shutdown()Step 5: Build the Package
Section titled “Step 5: Build the Package”Optionally check the layout first — cloacinactl package validate runs the same
schema + workflow//entry_module checks as the server, against a source
directory or a packed archive, without uploading:
cloacinactl package validate .Then pack. cloacinactl package pack reads package.toml, runs the same
validation, and emits the .cloacina archive:
cloacinactl package pack . --out data-pipeline-1.0.0.cloacinaA .cloacina package is a bzip2-compressed tar archive of package.toml + the
workflow/ tree (+ vendor/ if present), under a single <name>-<version>/
top-level directory:
data-pipeline-1.0.0.cloacina└── data-pipeline-1.0.0/ ├── package.toml ├── workflow/ │ └── data_pipeline/ │ └── tasks.py └── vendor/ # if presentBuilding the archive by hand (no cloacinactl)
The archive is a plain bzip2 tar, so you can build it with standard tools:
name=data-pipelineversion=1.0.0prefix="$name-$version"
stage="$(mktemp -d)/$prefix"mkdir -p "$stage"cp package.toml "$stage/"cp -R workflow "$stage/"[ -d vendor ] && cp -R vendor "$stage/"
tar -cjf "$name-$version.cloacina" -C "$(dirname "$stage")" "$prefix"Step 6: Deploy
Section titled “Step 6: Deploy”To the Daemon
Section titled “To the Daemon”Copy the .cloacina file into one of the daemon’s watched directories:
cp data-pipeline-1.0.0.cloacina ~/.cloacina/packages/The daemon’s reconciler detects it, extracts the archive, imports your
entry_module, registers the tasks, and registers the workflow.
To the Server
Section titled “To the Server”Upload via the HTTP API (or cloacinactl package upload):
curl -X POST \ -H "Authorization: Bearer $API_KEY" \ -F "file=@data-pipeline-1.0.0.cloacina" \ https://cloacina.example.com/v1/tenants/my_tenant/workflowsExternal Configuration with var/var_or
Section titled “External Configuration with var/var_or”Use cloaca.var() and cloaca.var_or() to read configuration at runtime instead
of hardcoding values:
# Resolved from CLOACINA_VAR_DATA_SOURCE environment variablesource = cloaca.var("DATA_SOURCE")
# With a default fallbacktimeout = cloaca.var_or("FETCH_TIMEOUT", "30")Set the variables on the host where the daemon or server runs:
export CLOACINA_VAR_DATA_SOURCE=postgres://analytics:pass@host/warehouseexport CLOACINA_VAR_FETCH_TIMEOUT=60See Variable Registry for details.
Troubleshooting
Section titled “Troubleshooting”“Missing workflow source directory”
Section titled ““Missing workflow source directory””Your module tree must live under a workflow/ directory at the package root. A
top-level module (<pkg>/<module>/) is rejected — move it to
<pkg>/workflow/<module>/.
“unknown field package_type” (or another [metadata] key)
Section titled ““unknown field package_type” (or another [metadata] key)”[metadata] is a closed schema. Remove package_type, [[metadata.triggers]],
or any other key not in the table in Step 1.
“entry_module not found”
Section titled ““entry_module not found””entry_module is a dotted path relative to workflow/. Verify the directory
matches (e.g. workflow/data_pipeline/tasks.py for
entry_module = "data_pipeline.tasks") and that __init__.py files exist.
Import times out after 60 seconds
Section titled “Import times out after 60 seconds”The loader enforces a 60-second import timeout. Move expensive module-level
initialization into task functions; the package is rejected with
"Python workflow import timed out after 60s" otherwise.
“stdlib shadowing detected”
Section titled ““stdlib shadowing detected””Remove or rename any vendored module that conflicts with the Python standard library (see the blocked list above).
“no tasks registered” (or “Empty package: registered no tasks”)
Section titled ““no tasks registered” (or “Empty package: registered no tasks”)”Tasks must register at import time with bare @cloaca.task decorators at
module level — the loader builds the workflow context from workflow_name, so
you do not wrap them in a WorkflowBuilder (wrapping shadows the loader’s
context and the package loads with zero tasks). Tasks defined inside a function or
behind an if __name__ == "__main__" guard also won’t be discovered on import.
The WorkflowBuilder in Step 4 is strictly for running the module in-process; it
is not part of the package.
See Also
Section titled “See Also”- Creating Your First Package — scaffold this layout with
cloacinactl package new - Package Format —
.cloacinaarchive structure - Running the Daemon — deploying to the local scheduler
- Deploying the API Server — deploying to the HTTP server