External task library and worker for https://operaton.org/
Use this package when you want to implement Operaton external service task workers in Python.
Install as a Python dependency:
pip install operaton-tasksInstall with CLI support:
pip install "operaton-tasks[cli]"- A decorator API for registering handlers by topic.
- A long-polling worker that fetches, locks, executes, and completes/fails external tasks.
- Optional CLI command to run the worker app.
- Built-in health endpoint at /healthz.
Requests to Operaton REST API can be authenticated in two supported ways.
Set ENGINE_REST_AUTHORIZATION to the full header value.
Example:
export ENGINE_REST_AUTHORIZATION="Basic base64-user-pass"
# or
export ENGINE_REST_AUTHORIZATION="Bearer my-static-token"Set the OAuth2 settings:
export OAUTH2_CLIENT_ID="my-client-id"
export OAUTH2_CLIENT_SECRET="my-client-secret"
export OAUTH2_TOKEN_URL="https://idp.example.com/realms/operaton/protocol/openid-connect/token"
export OAUTH2_SCOPES="scope-a scope-b" # optional, space-separatedWhen OAuth2 is configured, the worker automatically fetches and refreshes bearer tokens.
Authorization is resolved in this order:
- Explicit authorization argument passed to session/request helpers.
- OAuth2 client credentials token (if fully configured).
- ENGINE_REST_AUTHORIZATION.
With OAuth2 enabled, a 401 response triggers one token invalidation and one retry.
Main environment variables:
- ENGINE_REST_BASE_URL (default: http://localhost:8080/engine-rest)
- ENGINE_REST_AUTHORIZATION (optional)
- OAUTH2_CLIENT_ID (optional)
- OAUTH2_CLIENT_SECRET (optional)
- OAUTH2_TOKEN_URL (optional)
- OAUTH2_SCOPES (optional)
- ENGINE_REST_TIMEOUT_SECONDS (default: 20)
- ENGINE_REST_POLL_TTL_SECONDS (default: 10)
- ENGINE_REST_LOCK_TTL_SECONDS (default: 30)
- TASKS_WORKER_ID (default: operaton-tasks-client)
- TASKS_HEARTBEAT_TOPIC (default: operaton.tasks.heartbeat)
- TASKS_MODULE (used by CLI/app startup to load your handlers module)
- TASKS_LIMIT (default: 0, stop after processing this many tasks)
- TASKS_RUN_TIMEOUT_SECONDS (default: 0, exit after this many seconds)
- LOG_LEVEL (default: DEBUG)
This is the quickest way to run your handlers as a process.
- Create a handlers module, for example my_tasks.py.
- Run operaton-tasks and pass your module path.
Example handler module:
from operaton.tasks.types import CompleteExternalTaskDto
from operaton.tasks.types import ExternalTaskComplete
from operaton.tasks.types import LockedExternalTaskDto
from operaton.tasks.types import VariableValueDto
from operaton.tasks.types import VariableValueType
import operaton.tasks
@operaton.tasks.register("hello-world", localVariables=True)
async def hello_world(task: LockedExternalTaskDto) -> ExternalTaskComplete:
return ExternalTaskComplete(
task=task,
response=CompleteExternalTaskDto(
workerId=task.workerId,
localVariables={
"message": VariableValueDto(
value="Hello World",
type=VariableValueType.String,
),
},
),
)Run it:
operaton-tasks serve ./my_tasks.py -- --host 0.0.0.0 --port 8000Pass auth options directly on the CLI if preferred:
operaton-tasks serve ./my_tasks.py \
--base-url http://localhost:8080/engine-rest \
--authorization "Basic base64-user-pass"Or with OAuth2:
operaton-tasks serve ./my_tasks.py \
--base-url http://localhost:8080/engine-rest \
--oauth2-client-id my-client-id \
--oauth2-client-secret my-client-secret \
--oauth2-token-url https://idp.example.com/realms/operaton/protocol/openid-connect/token \
--oauth2-scopes "scope-a scope-b"Note: arguments after -- are passed through to uvicorn.
If you want the CLI to process a bounded number of tasks and then exit, pass the worker controls directly:
operaton-tasks serve ./my_tasks.py --limit 10 --run-timeout 60When either --limit or --run-timeout is set, the CLI loads the handler module,
runs the worker in one-shot mode, and exits when the limit or timeout is reached.
Use this mode when you already have your own Python app/runtime and want to embed the worker.
import asyncio
import operaton.tasks
from operaton.tasks.api import external_task_worker
from operaton.tasks.api import handlers
from operaton.tasks.types import CompleteExternalTaskDto
from operaton.tasks.types import ExternalTaskComplete
from operaton.tasks.types import LockedExternalTaskDto
@operaton.tasks.task("hello-world")
async def hello_world(task: LockedExternalTaskDto) -> ExternalTaskComplete:
return ExternalTaskComplete(
task=task,
response=CompleteExternalTaskDto(workerId=task.workerId),
)
if __name__ == "__main__":
asyncio.run(external_task_worker(handlers))Configure auth and connection through environment variables, or set values programmatically through operaton.tasks.settings before starting the worker.
If you want to reuse built-in routes such as /healthz and run the worker in your own FastAPI service:
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from operaton.tasks.api import external_task_worker
from operaton.tasks.api import handlers
from operaton.tasks.api import router
@asynccontextmanager
async def lifespan(app: FastAPI):
asyncio.create_task(external_task_worker(handlers))
yield
app = FastAPI(lifespan=lifespan)
app.include_router(router)- Handlers are async functions registered with @task(topic) or @register(topic).
- Input is LockedExternalTaskDto.
- Return ExternalTaskComplete for success (with CompleteExternalTaskDto or ExternalTaskBpmnError).
- If your handler raises an exception, it is converted to ExternalTaskFailure automatically.
GET /healthz is included on the built-in router.
- Without heartbeat activity, it checks Operaton engine reachability.
- With heartbeat activity, it verifies recent heartbeat timestamps.
This project uses devenv.sh for reproducible development environments with Nix.
Enter the development shell:
devenv shell
# or
make shellBuild and link the virtual environment:
make envRun all checks (linting, type checking, unit tests):
make testRun individual checks:
make check # treefmt, flake8, mypy
make test-pytest # Unit tests only
make devenv-test # Integration tests with Operaton + Keycloak servicesStart background services (Operaton + Keycloak) for manual testing:
make devenv-up # Start services
make devenv-down # Stop servicesWatch mode for development:
make watch # Run app in reload mode
make watch-tests # Continuously run mypy + pytestThis project includes GitHub Actions and GitLab CI pipelines configured with devenv.sh best practices.
GitHub Actions (.github/workflows/):
ci.yml: Runs on push, pull requests, and manual dispatch. Executes linting, type checking, and integration tests.release.yml: Publishes to PyPI on version tags (v*).
GitLab CI (.gitlab-ci.yml):
- Lint stage: treefmt, flake8, mypy
- Test stage: Unit and integration tests with live Operaton + Keycloak services
- Build & Publish stages: Triggered on tags
Both pipelines use:
- Nix for reproducible environments (via
devenv) - Magic Nix Cache (GitHub) or Nix cache directories (GitLab) for faster builds
devenv shell -- make <target>to ensure identical environments between local development and CI
For PyPI publishing, configure:
- GitHub Actions: Use trusted publishers (OIDC) or set
PYPI_API_TOKENsecret - GitLab CI: Set
PYPI_TOKENCI/CD variable with your PyPI token