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.)
- A working Python workflow (see Python tutorials)
cloacainstalled (pip install cloaca)- Understanding of the daemon or server deployment model
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 here
Key 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.)
[package]
name = "data-pipeline"
version = "1.0.0"
interface = "cloacina-workflow-plugin"
interface_version = 1
extension = "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.
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 context
The 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).
Bare decorators — not WorkflowBuilderIn a packaged workflow, do not wrap tasks in
with cloaca.WorkflowBuilder(...). That context manager is for running a workflow in-process (it pushes its own workflow context); inside a package it shadows the loader’s context, so the loader finds no tasks under yourworkflow_nameand rejects the package with “Empty package: registered no tasks”. Use bare@cloaca.taskdecorators and letworkflow_nameinpackage.tomlname the workflow.All decorators must run at import time (module level) — if registration is gated behind
if __name__ == "__main__", the loader won’t find the tasks.
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__.py
Stdlib ShadowingThe loader rejects any package that shadows Python standard library modules. You cannot vendor modules named
os,sys,json,pathlib,subprocess, or other stdlib names. This is a security measure to prevent code injection.Blocked modules include:
os,sys,subprocess,shutil,socket,http,urllib,ctypes,importlib,pathlib,io,json,pickle,marshal,code,codeop,compile,compileall,builtins,signal,multiprocessing,threading,tempfile,glob,fnmatch.
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()
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.cloacina
A .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 present
Packing fails fast on a bad layoutIf the module tree isn’t underworkflow/, orentry_moduledoesn’t resolve to a module there,packerrors immediately — you don’t have to wait for the server to reject the upload. The same parse rejectspackage_typeand[[metadata.triggers]].
Building the archive by hand (no cloacinactl)
The archive is a plain bzip2 tar, so you can build it with standard tools:
name=data-pipeline
version=1.0.0
prefix="$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"
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.
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/workflows
Use cloaca.var() and cloaca.var_or() to read configuration at runtime instead
of hardcoding values:
# Resolved from CLOACINA_VAR_DATA_SOURCE environment variable
source = cloaca.var("DATA_SOURCE")
# With a default fallback
timeout = 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/warehouse
export CLOACINA_VAR_FETCH_TIMEOUT=60
See Variable Registry for details.
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>/.
[metadata] is a closed schema. Remove package_type, [[metadata.triggers]],
or any other key not in the table in Step 1.
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.
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.
Remove or rename any vendored module that conflicts with the Python standard library (see the blocked list above).
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.
- 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