Skip to main content

A simple utility to run multiple Python scripts sequentially or in parallel, with isolated environments, monitoring and error handling.

Project description

uv-task-runner

Run multiple Python scripts in parallel or in sequence, with per-script dependency and Python version isolation via uv.

Each script is invoked as uv run <script>, so scripts can declare their own dependencies and Python version using PEP 723 inline metadata. No more shared mega-environments.

PyPI Python version

ty Coverage CI/CD GitHub issues


Requirements

Installation

🛈 Note for Allen Institute Windows users

The default path uv uses for downloaded Python versions is blocked from running executables by institute security policies. To work around this, environment variables can be set a different location.

This powershell command installs uv and configures it to use a location that is known to work on Allen Windows machines:

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
[System.Environment]::SetEnvironmentVariable('UV_PYTHON_INSTALL_DIR', '%USERPROFILE%\.uv\python', 'User')
[System.Environment]::SetEnvironmentVariable('UV_TOOL_BIN_DIR', '%USERPROFILE%\.uv\bin', 'User')

Make CLI tool available globally:

uv install uv-task-runner

Or run in a temporary environment:

uvx uv-task-runner              # `uvx` is an alias for `uv run tool`

Or add library to Python 3.8+ project:

uv add uv-task-runner

Usage

CLI

Generate an annotated template config file in the current directory:

uv run uv-task-runner --init                # writes uv_task_runner.toml
uv run uv-task-runner --init my_tasks.toml  # custom path

Or write it by hand. Minimal uv_task_runner.toml:

# Tasks are executed in order below if parallel=false (default):
[[tasks]]
task_path = "scripts/preprocess.py"

[[tasks]]
task_path = "scripts/analyze.py"
task_args = ["--output", "results/"]

Then run:

uv run uv-task-runner

Use a different config file:

uv run uv-task-runner --config path/to/config.toml

Override settings at the command line (CLI args take precedence over TOML):

uv run uv-task-runner --parallel --fail-fast --log-level DEBUG

Tasks can also be passed directly via --tasks as a JSON array (the TOML config is recommended for anything beyond a quick one-off, as shell escaping is error-prone):

# Single task
uv run uv-task-runner --tasks "[{\"task_path\":\"scripts/my_script.py\"}]"

# Multiple tasks with args
uv run uv-task-runner --tasks "[{\"task_path\":\"scripts/a.py\"},{\"task_path\":\"scripts/b.py\",\"task_args\":[\"--verbose\"]}]"

Note: double quotes inside the JSON must be escaped with \". All TaskConfig fields are supported.

Example output

Given a uv_task_runner.toml:

# Tasks are executed in order below if parallel=false (default):
[[tasks]]
task_path = "examples/script_a.py"
task_args = ["--param1", "updated_value"]
wait = false # don't wait for script_a.py to finish before starting the next task

[[tasks]]
task_path = "https://gist.githubusercontent.com/TAJD/1d389deba4221343caef5155090674eb/raw/13984206c008fdb35d2d574fa76b682991f00a08/error_handling.py"

[[tasks]]
task_path = "examples/script_b.py"
# if script does not declare dependencies with PEP 723 metadata it's possible to customize uv run args:
uv_args = ["--python", "3.14", "--verbose", "--script", "--no-project"]

[[tasks]]
task_path = "examples/script_c.py"

Running uv run uv-task-runner produces:

2026-03-02 13:32:27 | INFO | Running 4 task(s).
2026-03-02 13:32:27 | INFO | Running command: uv run --quiet --script --no-project examples/script_a.py --param1 updated_value
2026-03-02 13:32:27 | INFO | examples/script_a.py is running: not waiting for it to finish.
2026-03-02 13:32:27 | INFO | Running command: uv run --quiet --script --no-project https://gist.githubusercontent.com/TAJD/1d389deba4221343caef5155090674eb/raw/13984206c008fdb35d2d574fa76b682991f00a08/error_handling.py
2026-03-02 13:32:27 | INFO | [error_handling.py:164824] Error: The divisor 'b' cannot be zero.
2026-03-02 13:32:27 | INFO | [error_handling.py:164824] Error: The divisor 'b' cannot be zero.
2026-03-02 13:32:27 | INFO | [error_handling.py:164824] Stack trace:
2026-03-02 13:32:27 | INFO | [error_handling.py:164824]   File "C:\Users\BEN~1.HAR\AppData\Local\Temp\error_handlingjKocFl.py", line 52, in <module>
2026-03-02 13:32:27 | INFO | [error_handling.py:164824]     simple_example()
2026-03-02 13:32:27 | INFO | [error_handling.py:164824]   File "C:\Users\BEN~1.HAR\AppData\Local\Temp\error_handlingjKocFl.py", line 47, in simple_example
2026-03-02 13:32:27 | INFO | [error_handling.py:164824]     result = divide_numbers_stacktrace(10, 0)
2026-03-02 13:32:27 | INFO | [error_handling.py:164824]   File "C:\Users\BEN~1.HAR\AppData\Local\Temp\error_handlingjKocFl.py", line 37, in divide_numbers_stacktrace
2026-03-02 13:32:27 | INFO | [error_handling.py:164824]     return nested_division()
2026-03-02 13:32:27 | INFO | [error_handling.py:164824]   File "C:\Users\BEN~1.HAR\AppData\Local\Temp\error_handlingjKocFl.py", line 34, in nested_division
2026-03-02 13:32:27 | INFO | [error_handling.py:164824]     stack_trace = ''.join(traceback.format_stack())
2026-03-02 13:32:27 | INFO | [error_handling.py:164824]
2026-03-02 13:32:27 | INFO | https://gist.githubusercontent.com/TAJD/1d389deba4221343caef5155090674eb/raw/13984206c008fdb35d2d574fa76b682991f00a08/error_handling.py completed successfully.
2026-03-02 13:32:27 | INFO | Running command: uv run --python 3.14 --verbose --script --no-project examples/script_b.py
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG uv 0.10.7 (08ab1a344 2026-02-27)
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Found project root: `C:\Users\ben.hardcastle\github\uv-plugin-architecture`
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG No workspace root found, using project root
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Ignoring discovered project due to `--no-project`
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG No project found; searching for Python interpreter
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Using request connect timeout of 10s and read timeout of 30s
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Searching for Python 3.14 in virtual environments, managed installations, search path, or registry
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Found `cpython-3.13.1-windows-x86_64-none` at `C:\Users\ben.hardcastle\github\uv-plugin-architecture\.venv\Scripts\python.exe` (active virtual environment)
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Skipping interpreter at `.venv\Scripts\python.exe` from active virtual environment: does not satisfy request `3.14`
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Found `cpython-3.13.1-windows-x86_64-none` at `C:\Users\ben.hardcastle\github\uv-plugin-architecture\.venv\Scripts\python.exe` (virtual environment)
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Skipping interpreter at `.venv\Scripts\python.exe` from virtual environment: does not satisfy request `3.14`
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Searching for managed installations at `C:\Users\ben.hardcastle\cache\uv\python`
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Skipping managed installation `cpython-3.13.1-windows-x86_64-none`: does not satisfy `3.14`
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Found `cpython-3.13.1-windows-x86_64-none` at `C:\Users\ben.hardcastle\github\uv-plugin-architecture\.venv\Scripts\python.exe` (first executable in the search path)
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Skipping interpreter at `.venv\Scripts\python.exe` from first executable in the search path: does not satisfy request `3.14`
2026-03-02 13:32:27 | INFO | [script_b.py:145032] INFO Fetching requested Python...
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Downloading https://github.com/astral-sh/python-build-standalone/releases/download/20260211/cpython-3.14.3%2B20260211-x86_64-pc-windows-msvc-install_only_stripped.tar.gz
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Extracting cpython-3.14.3-20260211-x86_64-pc-windows-msvc-install_only_stripped.tar.gz to temporary location: C:\Users\ben.hardcastle\cache\uv\python\.temp\.tmpajp9EC
2026-03-02 13:32:27 | INFO | [script_b.py:145032] Downloading cpython-3.14.3-windows-x86_64-none (download) (21.3MiB)
2026-03-02 13:32:35 | INFO | [script_a.py:162304] script_a.py loaded polars version 1.38.1
2026-03-02 13:32:35 | INFO | [script_a.py:162304] script_a.py running on Python 3.11.9
2026-03-02 13:32:35 | INFO | [script_a.py:162304] script_a.py successfully received param1 from command line: updated_value       
2026-03-02 13:32:35 | INFO | [script_a.py:162304] script_a.py finished
2026-03-02 13:32:38 | INFO | [script_b.py:145032]  Downloaded cpython-3.14.3-windows-x86_64-none (download)
2026-03-02 13:32:38 | INFO | [script_b.py:145032] DEBUG Moving C:\Users\ben.hardcastle\cache\uv\python\.temp\.tmpajp9EC\python to C:\Users\ben.hardcastle\cache\uv\python\cpython-3.14.3-windows-x86_64-none
2026-03-02 13:32:38 | INFO | [script_b.py:145032] DEBUG Created link C:\Users\ben.hardcastle\cache\uv\python\cpython-3.14-windows-x86_64-none -> C:\Users\ben.hardcastle\cache\uv\python\cpython-3.14.3-windows-x86_64-none
2026-03-02 13:32:39 | INFO | [script_b.py:145032] DEBUG Using Python 3.14.3 interpreter at: C:\Users\ben.hardcastle\cache\uv\python\cpython-3.14.3-windows-x86_64-none\python.exe
2026-03-02 13:32:39 | INFO | [script_b.py:145032] DEBUG Running `python examples/script_b.py`
2026-03-02 13:32:39 | INFO | [script_b.py:145032] script_b.py loaded on Python 3.14.3
2026-03-02 13:32:39 | INFO | [script_b.py:145032] Traceback (most recent call last):
2026-03-02 13:32:39 | INFO | [script_b.py:145032]   File "C:\Users\ben.hardcastle\github\uv-plugin-architecture\scripts\script_b.py", line 5, in <module>
2026-03-02 13:32:39 | INFO | [script_b.py:145032]     raise ValueError(f"Simulated error in {Path(__file__).name}")
2026-03-02 13:32:39 | INFO | [script_b.py:145032] ValueError: Simulated error in script_b.py
2026-03-02 13:32:39 | INFO | [script_b.py:145032] DEBUG Command exited with code: 1
2026-03-02 13:32:39 | ERROR | examples/script_b.py failed with exit code 1
2026-03-02 13:32:39 | INFO | Running command: uv run --quiet --script --no-project examples/script_c.py
2026-03-02 13:32:44 | INFO | [script_c.py:36208] script_c.py loaded on Python 3.13.1
2026-03-02 13:32:44 | INFO | [script_c.py:36208] script_c.py finished
2026-03-02 13:32:44 | INFO | examples/script_c.py completed successfully.

Key things to note:

  • script_a.py has wait=false: it starts immediately and execution continues without waiting for it. With log_multiline=true, its output is buffered until exit — but since the parent exits first, no output is captured and a warning is emitted.
  • error_handling.py is fetched from a URL. Its multiline stderr (a stack trace) is emitted as a single log block because log_multiline=true.
  • script_b.py exits non-zero, logged at ERROR level.
  • script_c.py mixes stdout lines directly into the log stream (lines without the [name:pid] prefix come from the script's own print() calls).

Python API

from uv_task_runner import run_tasks, TaskConfig

results = run_tasks([
    TaskConfig(task_path="scripts/preprocess.py"),
    TaskConfig(task_path="scripts/analyze.py", task_args=["--output", "results/"]),
])

for r in results.task_results:
    print(r.task_path, r.exit_code, r.duration_seconds)

For more control, use Pipeline directly:

from uv_task_runner import Pipeline, Settings, TaskConfig

pipeline = Pipeline(
    tasks=[
        TaskConfig(task_path="scripts/a.py"),
        TaskConfig(task_path="scripts/b.py"),
    ],
    parallel=True,
    fail_fast=True,
)
result = pipeline.run()
print(result.aborted, result.aborted_by)

Configuration reference

Global settings applied to Pipeline

Key Type Default Description
parallel bool false Run all tasks concurrently. false runs them one at a time in listed order.
fail_fast bool false Terminate remaining tasks on the first failure.
dry_run bool false Print what would run without executing any tasks.
log_level string or int "INFO" Standard logging level names: DEBUG, INFO, WARNING, ERROR, CRITICAL, case-insensitive.
log_multiline bool false Buffer each task's stdout/stderr and emit as a single log message per stream. Default false logs lines as they arrive. With parallel=true, interleaved output from concurrent tasks can make multiline output (e.g. stack traces) hard to read: set log_multiline=true to keep them together at the cost of buffering until process exit. Has no readability effect when parallel=false.

Per-task settings applied to TaskConfig

Key Type Default Description
task_path string required Path to the script, relative to the config file. Can also be a URL (e.g. a GitHub raw file).
task_args list[string] [] Arguments passed to the script (sys.argv).
uv_command list[string] ["uv", "run"] The uv command prefix. Override to ["uvx"] to run a package CLI tool instead of a Python script (also set uv_args = [] to clear script-specific defaults).
uv_args list[string] ["--quiet", "--script", "--no-project"] Arguments inserted between the uv command and the script path.
wait bool true Wait for the task to finish before proceeding. false spawns the process and continues immediately.

Callback hooks (Python API only)

TaskConfig accepts Python callables for on_task_start and on_task_end. These are not settable via TOML.

def on_start(task_path: str, pid: int) -> None:
    print(f"Started {task_path} (PID {pid})")

def on_end(task_path: str, result: TaskResult) -> None:
    print(f"{task_path} exited {result.exit_code} after {result.duration_seconds:.1f}s")

TaskConfig(
    task_path="scripts/a.py",
    on_task_start=on_start,
    on_task_end=on_end,
)

Pipeline accepts on_pipeline_start and on_pipeline_end in the same way.

Hooks run synchronously in the parent process. Keep them fast; for slow file copying operations, open a background thread inside the hook.


How scripts are run

Each task is executed as:

[uv_command] [uv_args] [task_path] [task_args]

Scripts can declare their own Python version and dependencies using PEP 723 metadata:

# /// script
# requires-python = ">=3.11"
# dependencies = ["polars>=0.20", "requests"]
# ///

import polars as pl
# ...

uv resolves and installs dependencies for each script independently. Scripts with different Python versions or incompatible dependency sets run without conflict.


Return values

Pipeline.run() and run_tasks() return a PipelineResult:

@dataclass
class PipelineResult:
    task_results: list[TaskResult]
    aborted: bool           # True if fail_fast triggered early termination
    aborted_by: str | None  # task_path that caused the abort, or None

Each TaskResult:

@dataclass
class TaskResult:
    task_path: str
    exit_code: int | None   # None if wait=False
    success: bool
    duration_seconds: float
    stdout: str             # Empty string if wait=False
    stderr: str             # Empty string if wait=False
    pid: int

The CLI entry point always exits with code 0. Inspect PipelineResult when using the Python API.


Limitations

No DAG-style task dependencies. Sequential pipelines with fail_fast=True naturally express linear chains ("run B only after A succeeds"). What is not supported is graph-style dependencies, e.g. "run C after both A and B succeed" when A and B run in parallel. To implement phased parallel execution, call run_tasks() or Pipeline.run() multiple times in sequence, or consider Snakemake, Airflow, Prefect, or similar tools.

log_multiline=true always buffers until process exit. Output is held in a stream.read() call that blocks until the subprocess closes stdout. For normal wait=true tasks this means output appears as a single block at the end rather than in real-time. For wait=false (fire-and-forget) tasks it is worse: if the parent exits before the subprocess finishes, the daemon thread is killed and no output is logged at all. The default (log_multiline=false) logs lines as they arrive, which avoids both problems at the cost of interleaved output from concurrent tasks.

TaskResult.stdout/stderr are always empty for wait=false tasks regardless of buffering mode, because the capture threads are not joined before the result is collected. The subprocess will be reported as still running on pipeline exit.

No per-task timeouts. A hung task will block indefinitely. As a workaround, wrap the script invocation with timeout (Unix) or a similar mechanism.

No task naming. Tasks are identified by task_path in results and log output. Long paths or URLs can make logs harder to read.

Automated testing of scripts is harder. Standard test runners (pytest, unittest) share a single virtual environment and cannot replicate per-script isolation. The best solution might be to extract testable logic into a package and keep the script as a thin entry point.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

uv_task_runner-0.1.3.tar.gz (14.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

uv_task_runner-0.1.3-py3-none-any.whl (17.8 kB view details)

Uploaded Python 3

File details

Details for the file uv_task_runner-0.1.3.tar.gz.

File metadata

  • Download URL: uv_task_runner-0.1.3.tar.gz
  • Upload date:
  • Size: 14.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.10.8 {"installer":{"name":"uv","version":"0.10.8","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}

File hashes

Hashes for uv_task_runner-0.1.3.tar.gz
Algorithm Hash digest
SHA256 d4824f64639f01cbbf9e02ffc4aa1fa17e991a6c298e7880b07107b733d8fb33
MD5 168b2166b51a184a9b36432bad891953
BLAKE2b-256 4f5e78a3492d9d32fac52c47343640065b238048c5a89634f99f80cf4670b399

See more details on using hashes here.

File details

Details for the file uv_task_runner-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: uv_task_runner-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 17.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.10.8 {"installer":{"name":"uv","version":"0.10.8","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}

File hashes

Hashes for uv_task_runner-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 b480e5f907c696de1b6e2672e8d343525a4f8b7cf55cb8f499c0e3ba7b5eee6d
MD5 d7dc3a345e1996844456858ab75b93d0
BLAKE2b-256 13cd8d1edefb8c8536d4ed15113bacb87f3cc7ec0774516e10ab0d8b27b58623

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page