Pirlo
Pirlo is a declarative DAG runner for typed Python plays. You write each step as a class, declare which steps it depends on, and pirlo builds the graph, gives every play a CLI subcommand, runs it on Prefect, caches results, and keeps a run history.
# Excerpt; the complete, runnable file is in "Write your own play" below.
@play(name="greet", description="Greets one name")
class GreetPlay(Play[Greeting]):
name: str = requires(
NamesPlay, each="names"
) # one instance per name, run in parallel
async def execute(self) -> Greeting:
result = await self.shell.run("echo", f"hello {self.name}")
return Greeting(text=result.output_tail)
pirlo greet --help # shows the DAG and every parameter
pirlo greet # runs NamesPlay, then one GreetPlay per name
Install
Requires Python 3.12 or newer.
pip install pirlo # or: uv tool install pirlo
pirlo --help # lists built-in commands and every discovered play
Quickstart: the built-in demo
pirlo demo_report_dag --help # prints the DAG below, then its options
pirlo demo_report_dag # runs it; prints a Run ID and the output payload
pirlo run show <RUN_ID> # status, parameters and plays of that run
pirlo run log <RUN_ID>/<PLAY_ID> # logs of one play (PLAY_ID is listed by `run show`)
+-------------------+
| demo_select_dates |
+-------------------+
*
+----------------------------+ +--------------------------+
| demo_download_report [map] | | demo_fetch_budget_target |
+----------------------------+ +--------------------------+
***** *****
+----------------------+
| demo_extract_summary |
+----------------------+
*
+-----------------+
| demo_report_dag |
+-----------------+
[map] marks a fanned-out play. The demo source is
src/pirlo/playbooks/demo/report_dag.py.
Write your own play
Create playbooks/hello.py in your project directory:
from typing import Annotated
from pirlo.core.decorators import play
from pirlo.core.models.blueprint import PlayOutput
from pirlo.core.models.parameters import Parameter
from pirlo.core.ports.play import Play, requires
class Names(PlayOutput):
names: list[str]
class Greeting(PlayOutput):
text: str
class Report(PlayOutput):
lines: list[str]
@play(name="hello_names", description="Lists who to greet")
class NamesPlay(Play[Names]):
async def execute(
self,
names: Annotated[
str, Parameter(help="Comma-separated names", env_name="HELLO_NAMES")
] = "ada,linus",
) -> Names:
return Names(names=names.split(","))
@play(name="hello_greet", description="Greets one name", retries=1, timeout=60)
class GreetPlay(Play[Greeting]):
name: str = requires(NamesPlay, each="names")
async def execute(self) -> Greeting:
result = await self.shell.run("echo", f"hello {self.name}")
return Greeting(text=result.output_tail)
@play(name="hello", description="Collects every greeting")
class ReportPlay(Play[Report]):
greetings: list[Greeting] = requires(GreetPlay)
async def execute(self) -> Report:
return Report(lines=sorted(g.text for g in self.greetings))
if __name__ == "__main__":
ReportPlay.cli() # lets `python playbooks/hello.py` work too
Then, from that directory:
pirlo hello --help
pirlo hello # output: {"lines": ["hello ada", "hello linus"]}
HELLO_NAMES=x,y pirlo hello_names
There is no registration step: the name in @play(name=...) is the subcommand.
Where pirlo looks for plays
Pirlo finds plays by parsing source files for @play(name=...), without
importing them. It scans, in this order (a later match with the same name wins):
- Built-in plays in
pirlo/playbooks/. ./src/and./playbooks/under the current working directory.$PIRLO_WORKSPACE/src/and$PIRLO_WORKSPACE/playbooks/.- Installed packages whose top-level name starts with
pirlo_. - Entries in
[tool.pirlo.playbooks]of./pyproject.toml, asname = "module.path:ClassName", used only when the name was not found above.
Programming model
| Construct | Meaning |
|---|---|
class MyPlay(Play[Out]) with async def execute(self, ...) -> Out |
One step. Out subclasses PlayOutput (a pydantic model). |
@play(name=, description=, version="1.0", retries=0, retry_delay=0, timeout=None) |
Registers the play under name. Retries, delay and timeout are in seconds. |
x: ParentOut = requires(ParentPlay) |
Inject the parent's whole output (broadcast). |
x: str = requires(ParentPlay, field="greeting") |
Inject one field of the parent's output. |
x: str = requires(ParentPlay, each="names") |
Fan out: one instance of this play per item of the parent's list field. |
xs: list[ChildOut] = requires(FannedOutPlay) |
Fan in: receive every fanned-out instance's output as a list. |
requires(ParentPlay, quarter="Q1") |
Pin a parameter of the parent for this edge. |
execute(self, p: Annotated[str, Parameter(help=..., env_name=..., short="-p", sensitive=False)] = default) |
A parameter. It becomes --p on the CLI. |
Parameter values come from, lowest to highest precedence: the default in
execute, the environment variable named by env_name, then the CLI flag.
Parameters of upstream plays are flags of the target play too (--help lists
them under "Upstream Dependency Options"). A value is passed to every play in
the DAG that declares a parameter with that name, so give play-specific
parameters specific names.
Masking. A parameter's value is replaced with *** in task logs and in run
history (pirlo run show, pirlo run list) when it is declared with
Parameter(sensitive=True) or its name contains password, secret, token,
api_key, credential, auth or private_key. This includes fields nested
inside links, so a link's API key is never stored in run history.
Inside execute, a play has self.logger (logging, routed into the play's
task log), self.ui (commentary, goal, header for terminal output),
self.shell (below) and self.play_id.
Caching. Each execution gets a content-addressed id from the play name,
version and inputs. A later run with the same id reuses the stored result.
Pass --force (alias --no-cache) to rerun everything, or bump version= to
invalidate one play.
Python API. await ReportPlay.run_play(param=value) runs the play and its
upstream plays and returns the typed output.
Rule: code under playbooks/ never imports prefect. The orchestrator is
an implementation detail behind the compiler.
Run external commands: self.shell
from pirlo.core.services.shell import CommandFailed
result = await self.shell.run("ls", "-la", cwd=project_dir, timeout_s=30)
result.exit_code, result.output_tail, result.log_path, result.duration_s
- Arguments are passed as a list, never through a shell, so parameter values cannot inject shell syntax.
- Output streams line by line into the play's log as it is printed (stdout and
stderr combined;
\rprogress updates count as lines). - The full raw output is written to
$PIRLO_WORKSPACE/shell-logs/<id>.log;output_tailholds the last 200 lines. - It raises
CommandFailed(with.result) on a timeout or an exit code outsideok_codes(default(0,)). Useok_codes={1}when failure is the expected outcome, e.g. a bug reproduction step. - On timeout or cancellation the whole process group is terminated.
cwd=andenv=(added to the inherited environment) are optional.
Links: credentials for LLM providers
A link is a named provider account (provider, API key, optional base URL and optional default model), so plays never hold secrets.
pirlo link create # interactive wizard
pirlo link create my-gemini --provider gemini --api-key "$GEMINI_API_KEY" --model gemini-2.5-flash
pirlo link list
pirlo link show NAME # API key masked
pirlo link delete NAME
pirlo link test NAME [--model MODEL] # sends one short prompt
Providers: dashscope, gemini, openai, anthropic, ollama. --model is
optional: it sets the link's default, and code can pass a different model per
call. With neither, a call fails with MissingModelError instead of letting the
provider choose.
Use a link in a play by declaring a LinkParameter; the CLI takes the link name
and the play receives the resolved LlmLink:
from pirlo.core.models.link import LlmLink
from pirlo.core.models.parameters import LinkParameter
async def execute(
self, llm: Annotated[LlmLink, LinkParameter(help="LLM link name")]
) -> Out:
model = llm.resolve_model(None) # explicit model, else the link default
pirlo my_play --llm my-gemini
Links are stored in $PIRLO_WORKSPACE/links.json. Links written by
pirlo connect live in $PIRLO_WORKSPACE/connect/links.json and take
precedence over same-named user links.
Run history
pirlo run list [-n LIMIT] [-s STATUS] [-p PLAY] # newest first (aliases: ls)
pirlo run show RUN_ID # alias: inspect
pirlo run log RUN_ID[/PLAY_ID] [-n TAIL] [-f] # -f follows live logs (alias: logs)
pirlo <play> -l # stream logs to the terminal while running
Scheduling and orchestrators
By default a play runs once, immediately, on a local temporary Prefect server. To use a long-lived Prefect server, create an orchestrator link and target it:
pirlo orchestrator create # wizard: server URL, work pool, code storage
pirlo orchestrator list
pirlo orchestrator verify NAME # also: show NAME, delete NAME
pirlo <play> --orchestrator NAME
pirlo <play> --orchestrator NAME --routine daily # or hourly, weekly, monthly, or a cron string
--routine registers a Prefect deployment on the link's work pool (default
pirlo-pool). It only fires if that Prefect server is running and a worker
serves the pool, so do not use it with the default temporary server.
Configuration
| Variable | Default | Purpose |
|---|---|---|
PIRLO_WORKSPACE |
~/.pirlo-pitch |
Links, run directories, shell logs, extra plays. |
PIRLO_LOG_LEVEL |
INFO |
Pirlo log level. |
PREFECT_HOME |
~/.prefect |
Prefect's own state, including the temporary server's database. |
Other commands
pirlo servestarts a local Prefect server and Ollama models in Docker.pirlo connectopens SSH tunnels to a remotepirlo serveand registers its Ollama models as links.
Known issues
- Remote and scheduled runs send secrets to the Prefect server. With
--orchestratorpointing at a server that has a work pool, or with--routine, resolved parameter values, including link API keys, are part of the deployment payload stored by that server, because the worker needs them to run the play. Local runs (the default) do not store them.
Architecture
Ports and adapters: core/ports defines interfaces, infrastructure/adapters
implements them, playbooks/ holds plays.
Play classes ──BlueprintExtractor──▶ PlayBlueprint (engine-agnostic DAG)
──PrefectCompiler─────▶ Prefect flow ──PrefectRunner──▶ result + run history
| Path | Contents |
|---|---|
src/pirlo/core/models/ |
Blueprint, links, commands, parameters. |
src/pirlo/core/ports/ |
Play, CommandRunner, LinkRepository, BlueprintCompiler, ... |
src/pirlo/core/services/ |
Blueprint extraction, idempotency ids, Shell, masking. |
src/pirlo/infrastructure/adapters/ |
CLI, Prefect orchestrator, local command runner, JSON link storage, DAG rendering. |
src/pirlo/playbooks/ |
Built-in plays (demo, serve, connect). |
New behaviour goes in a new class implementing an existing port; see
AGENTS.md for contributor rules.
Development
uv sync # install (managed by uv)
uv run pytest # tests
make all # ruff format + ruff check + mypy
If ~/.prefect or ~/.pirlo-pitch is not writable (e.g. in a sandbox):
PREFECT_HOME=/tmp/prefect-home PIRLO_WORKSPACE=/tmp/pirlo-pitch uv run pytest
Release: bump __version__ in src/pirlo/__init__.py, commit on main, then
push a tag v<version>. The tag triggers the PyPI release workflow, which fails
if the tag and __version__ differ.
Glossary
Pirlo is named after the Italian midfielder Andrea Pirlo.
| Term | Meaning |
|---|---|
| Play | One step: a Play subclass registered with @play. |
| Playbook | A module of plays, e.g. playbooks/hello.py. |
| Pitch | The workspace directory, ~/.pirlo-pitch by default. |
| Link | A named connection: an LLM provider account or an orchestrator. |
| Routine | A recurring schedule for a play (--routine). |
Metadata
Release files for pirlo 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| pirlo-0.2.0.tar.gz | 409.0 kB | Without provenance | Details for pirlo-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pirlo-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for pirlo-0.2.0-py3-none-any.whl |
Total release size: 554.1 kB
Release files / pirlo-0.2.0.tar.gz
| Download URL | pirlo-0.2.0.tar.gz |
|---|---|
| Size | 409.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e2d347d4cb57661519ea533c46c7f9b29db50c235043088700becb140c8d7a70
|
|
BLAKE2b-256 checksum How to use checksums |
338d6dbc3b89793b72c4b27c46a0f247b6f87788286c2ea6c5cc5a984608d78c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / pirlo-0.2.0-py3-none-any.whl
| Download URL | pirlo-0.2.0-py3-none-any.whl |
|---|---|
| Size | 145.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6a740d9cad600cbb550aa49e75e799359390cce94ab5f393388e13c5881dc5a7
|
|
BLAKE2b-256 checksum How to use checksums |
8177e5a90ddc42562c58e7ab6d75868ae78a5fe1632270c8e17f875c9b0928a4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for pirlo 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.