uv-task-runner 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uv_task_runner/__init__.py +29 -0
- uv_task_runner/__main__.py +92 -0
- uv_task_runner/errors.py +6 -0
- uv_task_runner/pipeline.py +152 -0
- uv_task_runner/py.typed +0 -0
- uv_task_runner/settings.py +85 -0
- uv_task_runner/task.py +209 -0
- uv_task_runner/template_config.toml +50 -0
- uv_task_runner/utils.py +28 -0
- uv_task_runner-0.1.1.dist-info/METADATA +353 -0
- uv_task_runner-0.1.1.dist-info/RECORD +13 -0
- uv_task_runner-0.1.1.dist-info/WHEEL +4 -0
- uv_task_runner-0.1.1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from uv_task_runner import errors, pipeline, settings, task
|
|
2
|
+
|
|
3
|
+
ConfigError = errors.ConfigError
|
|
4
|
+
TaskError = errors.TaskError
|
|
5
|
+
OnPipelineEnd = pipeline.OnPipelineEnd
|
|
6
|
+
OnPipelineStart = pipeline.OnPipelineStart
|
|
7
|
+
Pipeline = pipeline.Pipeline
|
|
8
|
+
PipelineResult = pipeline.PipelineResult
|
|
9
|
+
run_tasks = pipeline.run_tasks
|
|
10
|
+
OnTaskEnd = task.OnTaskEnd
|
|
11
|
+
OnTaskStart = task.OnTaskStart
|
|
12
|
+
Settings = settings.Settings
|
|
13
|
+
TaskConfig = task.TaskConfig
|
|
14
|
+
TaskResult = task.TaskResult
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ConfigError",
|
|
18
|
+
"TaskError",
|
|
19
|
+
"OnPipelineEnd",
|
|
20
|
+
"OnPipelineStart",
|
|
21
|
+
"OnTaskEnd",
|
|
22
|
+
"OnTaskStart",
|
|
23
|
+
"Pipeline",
|
|
24
|
+
"PipelineResult",
|
|
25
|
+
"run_tasks",
|
|
26
|
+
"Settings",
|
|
27
|
+
"TaskConfig",
|
|
28
|
+
"TaskResult",
|
|
29
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.8"
|
|
3
|
+
# dependencies = [
|
|
4
|
+
# "pydantic-settings>=2.13.1",
|
|
5
|
+
# ]
|
|
6
|
+
# ///
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pydantic_settings import (
|
|
14
|
+
BaseSettings,
|
|
15
|
+
CliSettingsSource,
|
|
16
|
+
PydanticBaseSettingsSource,
|
|
17
|
+
TomlConfigSettingsSource,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from uv_task_runner import pipeline, settings
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _CliSettings(settings.Settings):
|
|
26
|
+
"""Settings subclass that adds CLI argument and TOML file parsing.
|
|
27
|
+
|
|
28
|
+
Used only by main(). Library users should construct Settings directly.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def settings_customise_sources(
|
|
33
|
+
cls,
|
|
34
|
+
settings_cls: type[BaseSettings],
|
|
35
|
+
init_settings: PydanticBaseSettingsSource,
|
|
36
|
+
env_settings: PydanticBaseSettingsSource,
|
|
37
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
38
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
39
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
40
|
+
config_path, cli_args = settings._parse_config_path()
|
|
41
|
+
return (
|
|
42
|
+
init_settings,
|
|
43
|
+
CliSettingsSource(
|
|
44
|
+
settings_cls,
|
|
45
|
+
cli_parse_args=cli_args,
|
|
46
|
+
cli_kebab_case=True,
|
|
47
|
+
cli_implicit_flags=True,
|
|
48
|
+
),
|
|
49
|
+
TomlConfigSettingsSource(settings_cls, toml_file=config_path),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> None:
|
|
54
|
+
# Handle --init as a unique case then exit:
|
|
55
|
+
if "--init" in sys.argv[1:]:
|
|
56
|
+
import argparse
|
|
57
|
+
|
|
58
|
+
p = argparse.ArgumentParser(add_help=False)
|
|
59
|
+
p.add_argument("--init", nargs="?", const=settings.DEFAULT_CONFIG_PATH)
|
|
60
|
+
args, _ = p.parse_known_args(sys.argv[1:])
|
|
61
|
+
try:
|
|
62
|
+
dest = settings.write_template_config(args.init)
|
|
63
|
+
print(f"Created {dest}")
|
|
64
|
+
except FileExistsError as exc:
|
|
65
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
66
|
+
raise SystemExit(1) from None
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
config_path, _ = settings._parse_config_path()
|
|
70
|
+
if not Path(config_path).exists():
|
|
71
|
+
if "--config" in sys.argv[1:]:
|
|
72
|
+
print(f"Error: config file not found: {config_path}", file=sys.stderr)
|
|
73
|
+
raise SystemExit(1)
|
|
74
|
+
print(
|
|
75
|
+
f"No config file found at '{Path(config_path).resolve()}'. "
|
|
76
|
+
f"Run --init <dest> to create a template, or pass --config <src>.",
|
|
77
|
+
file=sys.stderr,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
s = _CliSettings()
|
|
81
|
+
|
|
82
|
+
logging.basicConfig(
|
|
83
|
+
level=s.log_level,
|
|
84
|
+
format="%(asctime)s | %(levelname)s | %(message)s",
|
|
85
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
pipeline.Pipeline.from_settings(s).run()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
main()
|
uv_task_runner/errors.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import concurrent.futures as cf
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from uv_task_runner import settings, task, utils
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PipelineResult:
|
|
16
|
+
task_results: tuple[task.TaskResult, ...]
|
|
17
|
+
aborted: bool
|
|
18
|
+
aborted_by: str | None # task_path that triggered fail_fast, or None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OnPipelineStart(Protocol):
|
|
22
|
+
def __call__(self) -> None: ... # pragma: no cover
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OnPipelineEnd(Protocol):
|
|
26
|
+
def __call__(self, pipeline_result: PipelineResult) -> None: ... # pragma: no cover
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Pipeline:
|
|
31
|
+
tasks: list[task.TaskConfig]
|
|
32
|
+
parallel: bool = False
|
|
33
|
+
fail_fast: bool = False
|
|
34
|
+
log_multiline: bool = False
|
|
35
|
+
dry_run: bool = False
|
|
36
|
+
on_pipeline_start: OnPipelineStart | list[OnPipelineStart] | None = None
|
|
37
|
+
on_pipeline_end: OnPipelineEnd | list[OnPipelineEnd] | None = None
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_settings(cls, s: settings.Settings) -> Pipeline:
|
|
41
|
+
return cls(
|
|
42
|
+
tasks=s.tasks,
|
|
43
|
+
parallel=s.parallel,
|
|
44
|
+
fail_fast=s.fail_fast,
|
|
45
|
+
log_multiline=s.log_multiline,
|
|
46
|
+
dry_run=s.dry_run,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def run(self) -> PipelineResult:
|
|
50
|
+
utils._call_hooks(self.on_pipeline_start)
|
|
51
|
+
if self.dry_run:
|
|
52
|
+
logger.info(f"DRY RUN: {len(self.tasks)} task(s) would run.")
|
|
53
|
+
else:
|
|
54
|
+
logger.info(f"Running {len(self.tasks)} task(s).")
|
|
55
|
+
|
|
56
|
+
task_results: list[task.TaskResult] = []
|
|
57
|
+
task_handles: dict[str, task._TaskHandle] = {}
|
|
58
|
+
aborted = False
|
|
59
|
+
aborted_by: str | None = None
|
|
60
|
+
|
|
61
|
+
def _execute(task_config: task.TaskConfig) -> task.TaskResult:
|
|
62
|
+
if self.dry_run:
|
|
63
|
+
result = task.dry_run_task(task_config)
|
|
64
|
+
utils._call_hooks(task_config.on_task_end, task_config.task_path, result)
|
|
65
|
+
return result
|
|
66
|
+
handle = task.run_task(task_config, log_multiline=self.log_multiline)
|
|
67
|
+
task_handles[task_config.task_path] = handle
|
|
68
|
+
result = task._collect_result(handle, wait=task_config.wait)
|
|
69
|
+
utils._call_hooks(task_config.on_task_end, task_config.task_path, result)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
def _should_abort(result: task.TaskResult) -> bool:
|
|
73
|
+
"""Log task outcome. Return True if fail_fast should trigger."""
|
|
74
|
+
if result.exit_code is None:
|
|
75
|
+
msg = f"{result.task_path} is running: not waiting for it to finish."
|
|
76
|
+
if self.log_multiline:
|
|
77
|
+
msg += " No output will be logged for this task, because log_multiline=true buffers until process exit."
|
|
78
|
+
logger.info(msg)
|
|
79
|
+
return False
|
|
80
|
+
if result.exit_code != 0:
|
|
81
|
+
logger.error(f"{result.task_path} failed with exit code {result.exit_code}")
|
|
82
|
+
return self.fail_fast
|
|
83
|
+
logger.info(f"{result.task_path} completed successfully.")
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
if self.parallel:
|
|
87
|
+
with cf.ThreadPoolExecutor() as executor:
|
|
88
|
+
future_to_config = {executor.submit(_execute, tc): tc for tc in self.tasks}
|
|
89
|
+
for future in cf.as_completed(future_to_config):
|
|
90
|
+
result = future.result()
|
|
91
|
+
task_results.append(result)
|
|
92
|
+
if _should_abort(result):
|
|
93
|
+
aborted = True
|
|
94
|
+
aborted_by = result.task_path
|
|
95
|
+
logger.warning("Fail fast enabled: terminating any tasks still running.")
|
|
96
|
+
for tp, handle in task_handles.items():
|
|
97
|
+
if handle.process.poll() is None:
|
|
98
|
+
logger.warning(f"Terminating {tp} with PID {handle.process.pid}")
|
|
99
|
+
task._terminate_tree(handle.process)
|
|
100
|
+
if sys.version_info >= (3, 9):
|
|
101
|
+
executor.shutdown(wait=False, cancel_futures=True) # pragma: no cover
|
|
102
|
+
else:
|
|
103
|
+
logger.warning("Python <3.9: pending futures cannot be cancelled.")
|
|
104
|
+
executor.shutdown(wait=False)
|
|
105
|
+
break
|
|
106
|
+
else:
|
|
107
|
+
for task_config in self.tasks:
|
|
108
|
+
result = _execute(task_config)
|
|
109
|
+
task_results.append(result)
|
|
110
|
+
if _should_abort(result):
|
|
111
|
+
aborted = True
|
|
112
|
+
aborted_by = result.task_path
|
|
113
|
+
logger.warning("Fail fast enabled, exiting.")
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
# Warn about still-running background processes
|
|
117
|
+
for tp, handle in task_handles.items():
|
|
118
|
+
if handle.process.poll() is None:
|
|
119
|
+
logger.warning(
|
|
120
|
+
f"{tp} with PID {handle.process.pid} is still running after main "
|
|
121
|
+
"process completed: subsequent messages from the task will not be "
|
|
122
|
+
"captured (Hint: set TaskConfig.wait=true to change this behavior)"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
pipeline_result = PipelineResult(
|
|
126
|
+
task_results=tuple(task_results),
|
|
127
|
+
aborted=aborted,
|
|
128
|
+
aborted_by=aborted_by,
|
|
129
|
+
)
|
|
130
|
+
utils._call_hooks(self.on_pipeline_end, pipeline_result)
|
|
131
|
+
return pipeline_result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def run_tasks(
|
|
135
|
+
tasks: list[task.TaskConfig],
|
|
136
|
+
parallel: bool = False,
|
|
137
|
+
fail_fast: bool = False,
|
|
138
|
+
log_multiline: bool = False,
|
|
139
|
+
dry_run: bool = False,
|
|
140
|
+
on_pipeline_start: OnPipelineStart | list[OnPipelineStart] | None = None,
|
|
141
|
+
on_pipeline_end: OnPipelineEnd | list[OnPipelineEnd] | None = None,
|
|
142
|
+
) -> PipelineResult:
|
|
143
|
+
"""Convenience wrapper: construct a Pipeline and run it."""
|
|
144
|
+
return Pipeline(
|
|
145
|
+
tasks=tasks,
|
|
146
|
+
parallel=parallel,
|
|
147
|
+
fail_fast=fail_fast,
|
|
148
|
+
log_multiline=log_multiline,
|
|
149
|
+
dry_run=dry_run,
|
|
150
|
+
on_pipeline_start=on_pipeline_start,
|
|
151
|
+
on_pipeline_end=on_pipeline_end,
|
|
152
|
+
).run()
|
uv_task_runner/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, field_validator
|
|
8
|
+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
|
|
9
|
+
|
|
10
|
+
from uv_task_runner import task
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG_PATH = Path("uv_task_runner.toml")
|
|
13
|
+
|
|
14
|
+
_TEMPLATE_CONFIG_PATH = Path(__file__).parent / "template_config.toml"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write_template_config(dest: Path | str = DEFAULT_CONFIG_PATH) -> Path:
|
|
18
|
+
"""Write the annotated default config to *dest*.
|
|
19
|
+
|
|
20
|
+
Returns the resolved path that was written.
|
|
21
|
+
Raises FileExistsError if *dest* already exists.
|
|
22
|
+
"""
|
|
23
|
+
dest = Path(dest)
|
|
24
|
+
if dest.is_dir():
|
|
25
|
+
dest = dest.resolve() / DEFAULT_CONFIG_PATH
|
|
26
|
+
if dest.exists():
|
|
27
|
+
raise FileExistsError(f"{dest} already exists. Delete it or choose a different path.")
|
|
28
|
+
dest.write_text(_TEMPLATE_CONFIG_PATH.read_text(encoding="utf-8"), encoding="utf-8")
|
|
29
|
+
return dest.resolve()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_config_path() -> tuple[str, list[str]]:
|
|
33
|
+
"""Pre-parse --config before full settings initialization.
|
|
34
|
+
|
|
35
|
+
Returns (config_path, remaining_args) where remaining_args is sys.argv[1:]
|
|
36
|
+
with --config and its value removed. Pass remaining_args to CliSettingsSource
|
|
37
|
+
so the --config flag is not treated as an unrecognised argument.
|
|
38
|
+
"""
|
|
39
|
+
import argparse
|
|
40
|
+
|
|
41
|
+
pre = argparse.ArgumentParser(add_help=False)
|
|
42
|
+
pre.add_argument("--config", default=DEFAULT_CONFIG_PATH)
|
|
43
|
+
args, remaining = pre.parse_known_args(sys.argv[1:])
|
|
44
|
+
return args.config, remaining
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Settings(BaseSettings):
|
|
48
|
+
parallel: bool = False
|
|
49
|
+
fail_fast: bool = False
|
|
50
|
+
dry_run: bool = False
|
|
51
|
+
log_level: str | int = "INFO"
|
|
52
|
+
# Emit subprocess output line-by-line (false) or buffer per stream (true).
|
|
53
|
+
# Line-by-line is the default: output appears in real-time and is not lost
|
|
54
|
+
# for wait=false tasks. Set to true to keep multiline output (e.g. stack
|
|
55
|
+
# traces) together at the cost of buffering until the subprocess exits:
|
|
56
|
+
log_multiline: bool = False
|
|
57
|
+
tasks: list[task.TaskConfig] = Field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def settings_customise_sources(
|
|
61
|
+
cls,
|
|
62
|
+
settings_cls: type[BaseSettings],
|
|
63
|
+
init_settings: PydanticBaseSettingsSource,
|
|
64
|
+
env_settings: PydanticBaseSettingsSource,
|
|
65
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
66
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
67
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
68
|
+
# Library-safe: only uses init kwargs. No CLI parsing, no TOML file.
|
|
69
|
+
# For CLI usage, see _CliSettings in __main__.py.
|
|
70
|
+
return (init_settings,)
|
|
71
|
+
|
|
72
|
+
@field_validator("log_level")
|
|
73
|
+
def validate_log_level(cls, v: str | int) -> str:
|
|
74
|
+
if isinstance(v, str) and v.isnumeric() or isinstance(v, int):
|
|
75
|
+
# Numeric value: look up the name
|
|
76
|
+
name = logging.getLevelName(int(v))
|
|
77
|
+
if name.startswith("Level "):
|
|
78
|
+
raise ValueError(f"Invalid log level: {v}")
|
|
79
|
+
return name
|
|
80
|
+
else:
|
|
81
|
+
# String name: normalise and verify it's a known level
|
|
82
|
+
level_name = v.upper()
|
|
83
|
+
if not isinstance(getattr(logging, level_name, None), int):
|
|
84
|
+
raise ValueError(f"Invalid log level: {v}")
|
|
85
|
+
return level_name
|
uv_task_runner/task.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import IO, Any, Callable, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
15
|
+
|
|
16
|
+
from uv_task_runner import utils
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class OnTaskStart(Protocol):
|
|
23
|
+
def __call__(self, task_path: str, pid: int) -> None: ... # pragma: no cover
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@runtime_checkable
|
|
27
|
+
class OnTaskEnd(Protocol):
|
|
28
|
+
def __call__(self, task_path: str, result: TaskResult) -> None: ... # pragma: no cover
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TaskConfig(BaseModel):
|
|
32
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
33
|
+
|
|
34
|
+
task_path: str
|
|
35
|
+
task_args: list[str] = Field(default_factory=list)
|
|
36
|
+
uv_run_args: list[str] = Field(default_factory=lambda: ["--quiet", "--script"])
|
|
37
|
+
wait: bool = True
|
|
38
|
+
# Hooks below are Python-only (not settable via TOML/CLI), and run in parent environment.
|
|
39
|
+
# Called synchronously; keep them fast. For slow operations open a background thread inside the hook itself.
|
|
40
|
+
on_task_start: OnTaskStart | list[OnTaskStart] | None = None
|
|
41
|
+
on_task_end: OnTaskEnd | list[OnTaskEnd] | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class TaskResult:
|
|
46
|
+
task_path: str
|
|
47
|
+
exit_code: int | None # None if wait=False
|
|
48
|
+
success: bool
|
|
49
|
+
duration_seconds: float
|
|
50
|
+
stdout: str # Empty string if wait=False
|
|
51
|
+
stderr: str # Empty string if wait=False
|
|
52
|
+
pid: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class _TaskHandle:
|
|
57
|
+
"""Internal: bundles a running process with its output-capture state."""
|
|
58
|
+
|
|
59
|
+
process: subprocess.Popen
|
|
60
|
+
stdout_thread: threading.Thread
|
|
61
|
+
stderr_thread: threading.Thread
|
|
62
|
+
stdout_capture: list[str]
|
|
63
|
+
stderr_capture: list[str]
|
|
64
|
+
task_path: str
|
|
65
|
+
start_time: float
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _pipe_to_log(
|
|
69
|
+
stream: IO[str],
|
|
70
|
+
log_fn: Callable[[str], None],
|
|
71
|
+
prefix: str,
|
|
72
|
+
buffer_output: bool = True,
|
|
73
|
+
capture: list[str] | None = None, # mutated in-place
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Read stream, send to logger, and optionally accumulate into capture list."""
|
|
76
|
+
if buffer_output:
|
|
77
|
+
content = stream.read()
|
|
78
|
+
if content.strip():
|
|
79
|
+
log_fn(f"{prefix}{content.rstrip()}")
|
|
80
|
+
if capture is not None:
|
|
81
|
+
capture.append(content)
|
|
82
|
+
else:
|
|
83
|
+
lines: list[str] = []
|
|
84
|
+
for line in stream:
|
|
85
|
+
log_fn(f"{prefix}{line.rstrip()}")
|
|
86
|
+
lines.append(line)
|
|
87
|
+
if capture is not None:
|
|
88
|
+
capture.append("".join(lines))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _terminate_tree(proc: subprocess.Popen) -> None:
|
|
92
|
+
"""Terminate a process and all its children."""
|
|
93
|
+
if sys.platform.startswith("win"):
|
|
94
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True)
|
|
95
|
+
else:
|
|
96
|
+
try:
|
|
97
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
98
|
+
except ProcessLookupError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _build_args(task_config: TaskConfig) -> list[str]:
|
|
103
|
+
if any(c in task_config.uv_run_args for c in ("--help", "-h")):
|
|
104
|
+
raise ValueError("uv_run_args cannot contain --help or -h")
|
|
105
|
+
if any(c in task_config.uv_run_args for c in ("--python", "-p")):
|
|
106
|
+
if "--no-project" not in task_config.uv_run_args:
|
|
107
|
+
logger.warning(
|
|
108
|
+
f"Detected --python without --no-project in uv_run_args for task {task_config.task_path}. "
|
|
109
|
+
"This may cause unexpected behavior if the task's Python version conflicts with the parent process's '.python-version' file. "
|
|
110
|
+
)
|
|
111
|
+
return ["uv", "run"] + task_config.uv_run_args + [task_config.task_path] + task_config.task_args
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def dry_run_task(task_config: TaskConfig) -> TaskResult:
|
|
115
|
+
"""Log the command that would run without executing it. Returns a synthetic TaskResult."""
|
|
116
|
+
logger.info("DRY RUN: would run: %s", " ".join(_build_args(task_config)))
|
|
117
|
+
return TaskResult(
|
|
118
|
+
task_path=task_config.task_path,
|
|
119
|
+
exit_code=0,
|
|
120
|
+
success=True,
|
|
121
|
+
duration_seconds=0.0,
|
|
122
|
+
stdout="",
|
|
123
|
+
stderr="",
|
|
124
|
+
pid=0,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run_task(
|
|
129
|
+
task_config: TaskConfig,
|
|
130
|
+
popen_kwargs: dict[str, Any] | None = None,
|
|
131
|
+
log_multiline: bool = False,
|
|
132
|
+
) -> _TaskHandle:
|
|
133
|
+
"""Spawn a uv run subprocess for task_config. Fires on_task_start hooks.
|
|
134
|
+
|
|
135
|
+
Returns a _TaskHandle. Call pipeline._collect_result() to wait and get TaskResult.
|
|
136
|
+
"""
|
|
137
|
+
task_path = task_config.task_path
|
|
138
|
+
args = _build_args(task_config)
|
|
139
|
+
kwargs = dict(popen_kwargs or {})
|
|
140
|
+
if sys.platform.startswith("win"):
|
|
141
|
+
kwargs.setdefault("start_new_session", True)
|
|
142
|
+
logger.debug(
|
|
143
|
+
"uv_run_args=%r task_args=%r popen_kwargs=%r",
|
|
144
|
+
task_config.uv_run_args,
|
|
145
|
+
task_config.task_args,
|
|
146
|
+
kwargs,
|
|
147
|
+
)
|
|
148
|
+
logger.info("Running command: %s", " ".join(args))
|
|
149
|
+
process = subprocess.Popen(
|
|
150
|
+
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, **kwargs
|
|
151
|
+
)
|
|
152
|
+
start_time = time.monotonic()
|
|
153
|
+
prefix = f"[{Path(task_path).name}:{process.pid}] "
|
|
154
|
+
logger.debug("Spawned pid=%s for %r", process.pid, task_path)
|
|
155
|
+
|
|
156
|
+
stdout_capture: list[str] = []
|
|
157
|
+
stderr_capture: list[str] = []
|
|
158
|
+
threads: list[threading.Thread] = []
|
|
159
|
+
for stream, capture in (
|
|
160
|
+
(process.stdout, stdout_capture),
|
|
161
|
+
(process.stderr, stderr_capture),
|
|
162
|
+
):
|
|
163
|
+
t = threading.Thread(
|
|
164
|
+
target=_pipe_to_log,
|
|
165
|
+
kwargs={
|
|
166
|
+
"stream": stream,
|
|
167
|
+
"log_fn": logger.info,
|
|
168
|
+
"prefix": prefix,
|
|
169
|
+
"buffer_output": log_multiline,
|
|
170
|
+
"capture": capture,
|
|
171
|
+
},
|
|
172
|
+
daemon=True,
|
|
173
|
+
)
|
|
174
|
+
t.start()
|
|
175
|
+
threads.append(t)
|
|
176
|
+
|
|
177
|
+
if task_config.on_task_start:
|
|
178
|
+
logger.debug("Calling on_task_start hooks for %r with pid=%s", task_path, process.pid)
|
|
179
|
+
utils._call_hooks(task_config.on_task_start, task_path, process.pid)
|
|
180
|
+
logger.debug("on_task_start hooks complete for %r", task_path)
|
|
181
|
+
|
|
182
|
+
return _TaskHandle(
|
|
183
|
+
process=process,
|
|
184
|
+
stdout_thread=threads[0],
|
|
185
|
+
stderr_thread=threads[1],
|
|
186
|
+
stdout_capture=stdout_capture,
|
|
187
|
+
stderr_capture=stderr_capture,
|
|
188
|
+
task_path=task_path,
|
|
189
|
+
start_time=start_time,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _collect_result(handle: _TaskHandle, wait: bool) -> TaskResult:
|
|
194
|
+
"""Wait for a running task (if wait=True) and return its TaskResult."""
|
|
195
|
+
if wait:
|
|
196
|
+
logger.debug("Waiting for pid=%s (%r) to exit", handle.process.pid, handle.task_path)
|
|
197
|
+
handle.process.wait()
|
|
198
|
+
handle.stdout_thread.join()
|
|
199
|
+
handle.stderr_thread.join()
|
|
200
|
+
logger.debug("pid=%s exited with code %s", handle.process.pid, handle.process.returncode)
|
|
201
|
+
return TaskResult(
|
|
202
|
+
task_path=handle.task_path,
|
|
203
|
+
exit_code=handle.process.returncode,
|
|
204
|
+
success=handle.process.returncode == 0,
|
|
205
|
+
duration_seconds=time.monotonic() - handle.start_time,
|
|
206
|
+
stdout=handle.stdout_capture[0] if handle.stdout_capture else "",
|
|
207
|
+
stderr=handle.stderr_capture[0] if handle.stderr_capture else "",
|
|
208
|
+
pid=handle.process.pid,
|
|
209
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# uv_task_runner.toml — configuration for uv-task-runner
|
|
2
|
+
#
|
|
3
|
+
# Run tasks sequentially or in parallel. Each [[tasks]] entry is one task.
|
|
4
|
+
# Paths are relative to this config file; GitHub raw file URLs are also supported.
|
|
5
|
+
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
# Global settings
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
# Run all tasks concurrently instead of sequentially (default: false)
|
|
11
|
+
parallel = false
|
|
12
|
+
|
|
13
|
+
# Abort remaining tasks as soon as one fails (default: false)
|
|
14
|
+
fail_fast = false
|
|
15
|
+
|
|
16
|
+
# Log the commands that would run without executing anything (default: false)
|
|
17
|
+
dry_run = false
|
|
18
|
+
|
|
19
|
+
# Logging verbosity: DEBUG | INFO | WARNING | ERROR | CRITICAL (default: "INFO")
|
|
20
|
+
log_level = "INFO"
|
|
21
|
+
|
|
22
|
+
# Buffer subprocess output per stream instead of streaming line-by-line.
|
|
23
|
+
# false (default): output appears in real-time; never lost for wait=false tasks.
|
|
24
|
+
# true: keeps multiline output (e.g. stack traces) together, but buffers until
|
|
25
|
+
# the subprocess exits.
|
|
26
|
+
log_multiline = false
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Tasks — add one [[tasks]] block per script to run
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
# Minimal example: only task_path is required.
|
|
33
|
+
# [[tasks]]
|
|
34
|
+
# task_path = "scripts/my_script.py"
|
|
35
|
+
|
|
36
|
+
# Full example showing all per-task options:
|
|
37
|
+
#
|
|
38
|
+
# [[tasks]]
|
|
39
|
+
# task_path = "scripts/my_script.py"
|
|
40
|
+
#
|
|
41
|
+
# # Arguments forwarded to the script itself (default: [])
|
|
42
|
+
# task_args = ["--param", "value"]
|
|
43
|
+
#
|
|
44
|
+
# # Arguments forwarded to `uv run` (default: ["--quiet", "--script"])
|
|
45
|
+
# # Note: --python without --no-project will warn; --help/-h are disallowed.
|
|
46
|
+
# uv_run_args = ["--quiet", "--script"]
|
|
47
|
+
#
|
|
48
|
+
# # Wait for this task to finish before continuing (default: true)
|
|
49
|
+
# # Set false to fire-and-forget; the process outlives the main runner.
|
|
50
|
+
# wait = true
|
uv_task_runner/utils.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _call_hooks(
|
|
7
|
+
hooks: Callable[..., None] | list[Callable[..., None]] | None,
|
|
8
|
+
*args: Any,
|
|
9
|
+
**kwargs: Any,
|
|
10
|
+
) -> None:
|
|
11
|
+
"""Call a single hook or each hook in a list with the given inputs.
|
|
12
|
+
No results are collected.
|
|
13
|
+
|
|
14
|
+
Hooks are called synchronously and block the caller until they return.
|
|
15
|
+
Keep hooks fast. If a hook needs to do slow I/O (e.g. network requests),
|
|
16
|
+
spawn a background thread inside the hook itself.
|
|
17
|
+
|
|
18
|
+
To pass extra context to a hook, use a closure or functools.partial:
|
|
19
|
+
|
|
20
|
+
def on_start(task_path: str, pid: int) -> None:
|
|
21
|
+
my_client.notify(task_path, pid, env=env)
|
|
22
|
+
|
|
23
|
+
TaskConfig(task_path="...", on_task_start=on_start)
|
|
24
|
+
"""
|
|
25
|
+
if hooks is None:
|
|
26
|
+
return
|
|
27
|
+
for hook in [hooks] if callable(hooks) else hooks:
|
|
28
|
+
hook(*args, **kwargs) # type: ignore[call-top-callable]
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: uv-task-runner
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A simple utility to run multiple Python scripts sequentially or in parallel, with isolated environments, monitoring and error handling.
|
|
5
|
+
Author: bjhardcastle
|
|
6
|
+
Author-email: bjhardcastle <ben.hardcastle@alleninstitute.org>
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Requires-Dist: pydantic-settings>=2
|
|
17
|
+
Requires-Dist: eval-type-backport ; python_full_version < '3.10'
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Project-URL: Issues, https://github.com/AllenNeuralDynamics/uv-task-runner/issues
|
|
20
|
+
Project-URL: Repository, https://github.com/AllenNeuralDynamics/uv-task-runner
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# uv-task-runner
|
|
24
|
+
|
|
25
|
+
Run multiple Python scripts in parallel or in sequence, with per-script dependency and Python version isolation via [uv](https://docs.astral.sh/uv/).
|
|
26
|
+
|
|
27
|
+
Each script is invoked as `uv run <script>`, so scripts can declare their own dependencies and Python version using [PEP 723 inline metadata](https://peps.python.org/pep-0723/). No more shared mega-environments.
|
|
28
|
+
|
|
29
|
+
[](https://pypi.org/project/uv-task-runner/)
|
|
30
|
+
[](https://pypi.org/project/uv-task-runner/)
|
|
31
|
+
|
|
32
|
+
[](https://github.com/astral-sh/ty)
|
|
33
|
+
[](https://app.codecov.io/github/AllenNeuralDynamics/uv-task-runner)
|
|
34
|
+
[](https://github.com/AllenNeuralDynamics/uv-task-runner/actions/workflows/publish.yaml)
|
|
35
|
+
[](https://github.com/AllenNeuralDynamics/uv-task-runner/issues)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- Python 3.8+
|
|
43
|
+
- `uv` on PATH. See https://docs.astral.sh/uv/getting-started/installation/
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
Make available globally:
|
|
48
|
+
```bash
|
|
49
|
+
uv install uv-task-runner
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or run CLI tool in temporary environment:
|
|
53
|
+
```bash
|
|
54
|
+
uv run uv-task-runner
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or add library to Python project:
|
|
58
|
+
```bash
|
|
59
|
+
uv add uv-task-runner
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
### CLI
|
|
67
|
+
|
|
68
|
+
Generate an annotated config file in the current directory:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
uv run uv-task-runner --init # writes uv_task_runner.toml
|
|
72
|
+
uv run uv-task-runner --init my_tasks.toml # custom path
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or write it by hand. Minimal `uv_task_runner.toml`:
|
|
76
|
+
|
|
77
|
+
```toml
|
|
78
|
+
[[tasks]]
|
|
79
|
+
task_path = "scripts/preprocess.py"
|
|
80
|
+
|
|
81
|
+
[[tasks]]
|
|
82
|
+
task_path = "scripts/analyze.py"
|
|
83
|
+
task_args = ["--output", "results/"]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Then run:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv run uv-task-runner
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Use a different config file:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
uv run uv-task-runner --config path/to/config.toml
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Override settings at the command line (CLI args take precedence over TOML):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv run uv-task-runner --parallel --fail-fast --log-level DEBUG
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
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):
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Single task
|
|
108
|
+
uv run uv-task-runner --tasks "[{\"task_path\":\"scripts/my_script.py\"}]"
|
|
109
|
+
|
|
110
|
+
# Multiple tasks with args
|
|
111
|
+
uv run uv-task-runner --tasks "[{\"task_path\":\"scripts/a.py\"},{\"task_path\":\"scripts/b.py\",\"task_args\":[\"--verbose\"]}]"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Note: double quotes inside the JSON must be escaped with `\"`. All `TaskConfig` fields are supported.
|
|
115
|
+
|
|
116
|
+
### Example output
|
|
117
|
+
|
|
118
|
+
Given a `uv_task_runner.toml`:
|
|
119
|
+
|
|
120
|
+
```toml
|
|
121
|
+
# Tasks are executed in order below if parallel=false (default):
|
|
122
|
+
[[tasks]]
|
|
123
|
+
task_path = "examples/script_a.py"
|
|
124
|
+
task_args = ["--param1", "updated_value"]
|
|
125
|
+
wait = false # don't wait for script_a.py to finish before starting the next task
|
|
126
|
+
|
|
127
|
+
[[tasks]]
|
|
128
|
+
task_path = "https://gist.githubusercontent.com/TAJD/1d389deba4221343caef5155090674eb/raw/13984206c008fdb35d2d574fa76b682991f00a08/error_handling.py"
|
|
129
|
+
|
|
130
|
+
[[tasks]]
|
|
131
|
+
task_path = "examples/script_b.py"
|
|
132
|
+
# if script does not declare dependencies with PEP 723 metadata it's possible to customize uv run args:
|
|
133
|
+
uv_run_args = ["--python", "3.14", "--verbose", "--script", "--no-project"]
|
|
134
|
+
|
|
135
|
+
[[tasks]]
|
|
136
|
+
task_path = "examples/script_c.py"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Running `uv run uv-task-runner` produces:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
2026-03-02 13:32:27 | INFO | Running 4 task(s).
|
|
143
|
+
2026-03-02 13:32:27 | INFO | Running command: uv run --quiet --script examples/script_a.py --param1 updated_value
|
|
144
|
+
2026-03-02 13:32:27 | INFO | examples/script_a.py is running: not waiting for it to finish.
|
|
145
|
+
2026-03-02 13:32:27 | INFO | Running command: uv run --quiet --script https://gist.githubusercontent.com/TAJD/1d389deba4221343caef5155090674eb/raw/13984206c008fdb35d2d574fa76b682991f00a08/error_handling.py
|
|
146
|
+
2026-03-02 13:32:27 | INFO | [error_handling.py:164824] Error: The divisor 'b' cannot be zero.
|
|
147
|
+
2026-03-02 13:32:27 | INFO | [error_handling.py:164824] Error: The divisor 'b' cannot be zero.
|
|
148
|
+
2026-03-02 13:32:27 | INFO | [error_handling.py:164824] Stack trace:
|
|
149
|
+
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>
|
|
150
|
+
2026-03-02 13:32:27 | INFO | [error_handling.py:164824] simple_example()
|
|
151
|
+
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
|
|
152
|
+
2026-03-02 13:32:27 | INFO | [error_handling.py:164824] result = divide_numbers_stacktrace(10, 0)
|
|
153
|
+
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
|
|
154
|
+
2026-03-02 13:32:27 | INFO | [error_handling.py:164824] return nested_division()
|
|
155
|
+
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
|
|
156
|
+
2026-03-02 13:32:27 | INFO | [error_handling.py:164824] stack_trace = ''.join(traceback.format_stack())
|
|
157
|
+
2026-03-02 13:32:27 | INFO | [error_handling.py:164824]
|
|
158
|
+
2026-03-02 13:32:27 | INFO | https://gist.githubusercontent.com/TAJD/1d389deba4221343caef5155090674eb/raw/13984206c008fdb35d2d574fa76b682991f00a08/error_handling.py completed successfully.
|
|
159
|
+
2026-03-02 13:32:27 | INFO | Running command: uv run --python 3.14 --verbose --script --no-project examples/script_b.py
|
|
160
|
+
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG uv 0.10.7 (08ab1a344 2026-02-27)
|
|
161
|
+
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Found project root: `C:\Users\ben.hardcastle\github\uv-plugin-architecture`
|
|
162
|
+
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG No workspace root found, using project root
|
|
163
|
+
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Ignoring discovered project due to `--no-project`
|
|
164
|
+
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG No project found; searching for Python interpreter
|
|
165
|
+
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Using request connect timeout of 10s and read timeout of 30s
|
|
166
|
+
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
|
|
167
|
+
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)
|
|
168
|
+
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`
|
|
169
|
+
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)
|
|
170
|
+
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`
|
|
171
|
+
2026-03-02 13:32:27 | INFO | [script_b.py:145032] DEBUG Searching for managed installations at `C:\Users\ben.hardcastle\cache\uv\python`
|
|
172
|
+
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`
|
|
173
|
+
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)
|
|
174
|
+
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`
|
|
175
|
+
2026-03-02 13:32:27 | INFO | [script_b.py:145032] INFO Fetching requested Python...
|
|
176
|
+
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
|
|
177
|
+
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
|
|
178
|
+
2026-03-02 13:32:27 | INFO | [script_b.py:145032] Downloading cpython-3.14.3-windows-x86_64-none (download) (21.3MiB)
|
|
179
|
+
2026-03-02 13:32:35 | INFO | [script_a.py:162304] script_a.py loaded polars version 1.38.1
|
|
180
|
+
2026-03-02 13:32:35 | INFO | [script_a.py:162304] script_a.py running on Python 3.11.9
|
|
181
|
+
2026-03-02 13:32:35 | INFO | [script_a.py:162304] script_a.py successfully received param1 from command line: updated_value
|
|
182
|
+
2026-03-02 13:32:35 | INFO | [script_a.py:162304] script_a.py finished
|
|
183
|
+
2026-03-02 13:32:38 | INFO | [script_b.py:145032] Downloaded cpython-3.14.3-windows-x86_64-none (download)
|
|
184
|
+
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
|
|
185
|
+
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
|
|
186
|
+
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
|
|
187
|
+
2026-03-02 13:32:39 | INFO | [script_b.py:145032] DEBUG Running `python examples/script_b.py`
|
|
188
|
+
2026-03-02 13:32:39 | INFO | [script_b.py:145032] script_b.py loaded on Python 3.14.3
|
|
189
|
+
2026-03-02 13:32:39 | INFO | [script_b.py:145032] Traceback (most recent call last):
|
|
190
|
+
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>
|
|
191
|
+
2026-03-02 13:32:39 | INFO | [script_b.py:145032] raise ValueError(f"Simulated error in {Path(__file__).name}")
|
|
192
|
+
2026-03-02 13:32:39 | INFO | [script_b.py:145032] ValueError: Simulated error in script_b.py
|
|
193
|
+
2026-03-02 13:32:39 | INFO | [script_b.py:145032] DEBUG Command exited with code: 1
|
|
194
|
+
2026-03-02 13:32:39 | ERROR | examples/script_b.py failed with exit code 1
|
|
195
|
+
2026-03-02 13:32:39 | INFO | Running command: uv run --quiet --script examples/script_c.py
|
|
196
|
+
2026-03-02 13:32:44 | INFO | [script_c.py:36208] script_c.py loaded on Python 3.13.1
|
|
197
|
+
2026-03-02 13:32:44 | INFO | [script_c.py:36208] script_c.py finished
|
|
198
|
+
2026-03-02 13:32:44 | INFO | examples/script_c.py completed successfully.
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Key things to note:
|
|
202
|
+
- `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.
|
|
203
|
+
- `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`.
|
|
204
|
+
- `script_b.py` exits non-zero, logged at `ERROR` level.
|
|
205
|
+
- `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).
|
|
206
|
+
|
|
207
|
+
### Python API
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from uv_task_runner import run_tasks, TaskConfig
|
|
211
|
+
|
|
212
|
+
results = run_tasks([
|
|
213
|
+
TaskConfig(task_path="scripts/preprocess.py"),
|
|
214
|
+
TaskConfig(task_path="scripts/analyze.py", task_args=["--output", "results/"]),
|
|
215
|
+
])
|
|
216
|
+
|
|
217
|
+
for r in results.task_results:
|
|
218
|
+
print(r.task_path, r.exit_code, r.duration_seconds)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
For more control, use `Pipeline` directly:
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from uv_task_runner import Pipeline, Settings, TaskConfig
|
|
225
|
+
|
|
226
|
+
pipeline = Pipeline(
|
|
227
|
+
tasks=[
|
|
228
|
+
TaskConfig(task_path="scripts/a.py"),
|
|
229
|
+
TaskConfig(task_path="scripts/b.py"),
|
|
230
|
+
],
|
|
231
|
+
parallel=True,
|
|
232
|
+
fail_fast=True,
|
|
233
|
+
)
|
|
234
|
+
result = pipeline.run()
|
|
235
|
+
print(result.aborted, result.aborted_by)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Configuration reference
|
|
241
|
+
|
|
242
|
+
### Global settings applied to `Pipeline`
|
|
243
|
+
|
|
244
|
+
| Key | Type | Default | Description |
|
|
245
|
+
|-----|------|---------|-------------|
|
|
246
|
+
| `parallel` | bool | `false` | Run all tasks concurrently. `false` runs them one at a time in listed order. |
|
|
247
|
+
| `fail_fast` | bool | `false` | Terminate remaining tasks on the first failure. |
|
|
248
|
+
| `dry_run` | bool | `false` | Print what would run without executing any tasks. |
|
|
249
|
+
| `log_level` | string or int | `"INFO"` | Standard logging level names: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, case-insensitive. |
|
|
250
|
+
| `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`. |
|
|
251
|
+
|
|
252
|
+
### Per-task settings applied to `TaskConfig`
|
|
253
|
+
|
|
254
|
+
| Key | Type | Default | Description |
|
|
255
|
+
|-----|------|---------|-------------|
|
|
256
|
+
| `task_path` | string | required | Path to the script, relative to the config file. Can also be a URL (e.g. a GitHub raw file). |
|
|
257
|
+
| `task_args` | list[string] | `[]` | Arguments passed to the script (`sys.argv`). |
|
|
258
|
+
| `uv_run_args` | list[string] | `["--quiet", "--script"]` | Arguments passed to `uv run` before the script path. |
|
|
259
|
+
| `wait` | bool | `true` | Wait for the task to finish before proceeding. `false` spawns the process and continues immediately. |
|
|
260
|
+
|
|
261
|
+
### Callback hooks (Python API only)
|
|
262
|
+
|
|
263
|
+
`TaskConfig` accepts Python callables for `on_task_start` and `on_task_end`. These are not settable via TOML.
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
def on_start(task_path: str, pid: int) -> None:
|
|
267
|
+
print(f"Started {task_path} (PID {pid})")
|
|
268
|
+
|
|
269
|
+
def on_end(task_path: str, result: TaskResult) -> None:
|
|
270
|
+
print(f"{task_path} exited {result.exit_code} after {result.duration_seconds:.1f}s")
|
|
271
|
+
|
|
272
|
+
TaskConfig(
|
|
273
|
+
task_path="scripts/a.py",
|
|
274
|
+
on_task_start=on_start,
|
|
275
|
+
on_task_end=on_end,
|
|
276
|
+
)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`Pipeline` accepts `on_pipeline_start` and `on_pipeline_end` in the same way.
|
|
280
|
+
|
|
281
|
+
Hooks run synchronously in the parent process. Keep them fast; for slow operations, open a background thread inside the hook.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## How scripts are run
|
|
286
|
+
|
|
287
|
+
Each task is executed as:
|
|
288
|
+
|
|
289
|
+
```
|
|
290
|
+
uv run [uv_run_args] [task_path] [task_args]
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Scripts can declare their own Python version and dependencies using PEP 723 metadata:
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
# /// script
|
|
297
|
+
# requires-python = ">=3.11"
|
|
298
|
+
# dependencies = ["polars>=0.20", "requests"]
|
|
299
|
+
# ///
|
|
300
|
+
|
|
301
|
+
import polars as pl
|
|
302
|
+
# ...
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`uv` resolves and installs dependencies for each script independently. Scripts with different Python versions or incompatible dependency sets run without conflict.
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Return values
|
|
310
|
+
|
|
311
|
+
`Pipeline.run()` and `run_tasks()` return a `PipelineResult`:
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
@dataclass
|
|
315
|
+
class PipelineResult:
|
|
316
|
+
task_results: list[TaskResult]
|
|
317
|
+
aborted: bool # True if fail_fast triggered early termination
|
|
318
|
+
aborted_by: str | None # task_path that caused the abort, or None
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Each `TaskResult`:
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
@dataclass
|
|
325
|
+
class TaskResult:
|
|
326
|
+
task_path: str
|
|
327
|
+
exit_code: int | None # None if wait=False
|
|
328
|
+
success: bool
|
|
329
|
+
duration_seconds: float
|
|
330
|
+
stdout: str # Empty string if wait=False
|
|
331
|
+
stderr: str # Empty string if wait=False
|
|
332
|
+
pid: int
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The CLI entry point always exits with code 0. Inspect `PipelineResult` when using the Python API.
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Limitations
|
|
340
|
+
|
|
341
|
+
**No DAG-style task dependencies.** Sequential pipelines with `fail_fast=True` naturally express
|
|
342
|
+
linear chains ("run B only after A succeeds"). What is not supported is graph-style dependencies,
|
|
343
|
+
e.g. "run C after both A and B succeed" when A and B run in parallel. To implement phased parallel
|
|
344
|
+
execution, call `run_tasks()` or `Pipeline.run()` multiple times in sequence, or consider Snakemake,
|
|
345
|
+
Airflow, Prefect, or similar tools.
|
|
346
|
+
|
|
347
|
+
**`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.
|
|
348
|
+
|
|
349
|
+
`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.
|
|
350
|
+
|
|
351
|
+
**No per-task timeouts.** A hung task will block indefinitely. As a workaround, wrap the script invocation with `timeout` (Unix) or a similar mechanism.
|
|
352
|
+
|
|
353
|
+
**No task naming.** Tasks are identified by `task_path` in results and log output. Long paths or URLs can make logs harder to read.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
uv_task_runner/__init__.py,sha256=aN6TA-qOIxLnTzmm9J3WPELA6v47gv5jOSOFp_ozXvY,689
|
|
2
|
+
uv_task_runner/__main__.py,sha256=AjJ5LfchuddF_FuOvDYW7ChWlgO3-EX9Q-_25T9zRmI,2655
|
|
3
|
+
uv_task_runner/errors.py,sha256=KbwjWWn_V22PFnSR7qQe9S4FJXIZ6sGF6dE__vvUNIg,246
|
|
4
|
+
uv_task_runner/pipeline.py,sha256=SU3nVu57V9jUcLgaMu2JptPDoDQcuHnoR9ACRM2t8bE,6013
|
|
5
|
+
uv_task_runner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
uv_task_runner/settings.py,sha256=yzf8BWVQUhVl7mMRjH8gwW48TKFUUgZsKrhX63iJgdg,3254
|
|
7
|
+
uv_task_runner/task.py,sha256=b8jLVPM1kmZHHZzAeZcbY6PX5Q-cH7XmKuixLeOtYt8,7053
|
|
8
|
+
uv_task_runner/template_config.toml,sha256=gGT4PJf0jKVQAQQnyFbNZG2-7cRT8oFqao1ZvB71PiI,1891
|
|
9
|
+
uv_task_runner/utils.py,sha256=asxG8N8kTmkQ_RexJaohL6fVN8CxHAmQ1crQ-9wSKiw,921
|
|
10
|
+
uv_task_runner-0.1.1.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
11
|
+
uv_task_runner-0.1.1.dist-info/entry_points.txt,sha256=6Z7L5bvTNHhU_8huNXXJGLIBeTNEGC7GcBPb-7eqjjs,88
|
|
12
|
+
uv_task_runner-0.1.1.dist-info/METADATA,sha256=6SrHYYXRCp7qQ6xkHlZomodkqu-niamsf9CtfknQVdc,18259
|
|
13
|
+
uv_task_runner-0.1.1.dist-info/RECORD,,
|