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.
@@ -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()
@@ -0,0 +1,6 @@
1
+ class ConfigError(Exception):
2
+ """Raised for invalid configuration: bad TOML, missing fields, invalid settings values."""
3
+
4
+
5
+ class TaskError(Exception):
6
+ """Raised for task infrastructure failures: uv not found, script path missing, etc."""
@@ -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()
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
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/uv-task-runner.svg?label=PyPI&color=blue)](https://pypi.org/project/uv-task-runner/)
30
+ [![Python version](https://img.shields.io/pypi/pyversions/uv-task-runner)](https://pypi.org/project/uv-task-runner/)
31
+
32
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
33
+ [![Coverage](https://img.shields.io/badge/coverage-98%25-green?logo=codecov)](https://app.codecov.io/github/AllenNeuralDynamics/uv-task-runner)
34
+ [![CI/CD](https://img.shields.io/github/actions/workflow/status/AllenNeuralDynamics/uv-task-runner/publish.yaml?label=CI/CD&logo=github)](https://github.com/AllenNeuralDynamics/uv-task-runner/actions/workflows/publish.yaml)
35
+ [![GitHub issues](https://img.shields.io/github/issues/AllenNeuralDynamics/uv-task-runner?logo=github)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.24
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ task = poethepoet:main
3
+ uv-task-runner = uv_task_runner.__main__:main
4
+