uv-script 0.1.5__tar.gz

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,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: uv-script
3
+ Version: 0.1.5
4
+ Summary: A lightweight script runner for uv — define and run project scripts from pyproject.toml
5
+ Author: Felix Simkovic
6
+ Author-email: Felix Simkovic <felixsimkovic@me.com>
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
11
+ # uv-script
12
+
13
+ [![PyPI](https://img.shields.io/pypi/v/uv-script)](https://pypi.org/project/uv-script/)
14
+
15
+ A lightweight, zero-dependency script runner for [uv](https://docs.astral.sh/uv/). Define project scripts in `pyproject.toml` and run them through `uv run`.
16
+
17
+ ```
18
+ $ uvs --list
19
+ check lint -> test
20
+ format ruff format src/
21
+ lint ruff check src/
22
+ test pytest tests/ -v
23
+ ```
24
+
25
+ ## Why?
26
+
27
+ uv is a fantastic package manager but has no built-in task runner. If you've been using [Hatch](https://hatch.pypa.io/) just for its scripts, or reaching for [Poe the Poet](https://poethepoet.natn.io/) / [Taskipy](https://github.com/taskipy/taskipy) to fill the gap, `uvs` offers a simpler alternative:
28
+
29
+ - **Zero runtime dependencies** — stdlib only (`tomllib`, `argparse`, `subprocess`)
30
+ - **uv-native** — every command runs through `uv run`, so your venv, lockfile, and Python version are always in sync
31
+ - **Familiar** — if you've used npm scripts or Hatch scripts, you already know how this works
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ # As a dev dependency in your project
37
+ uv add --dev uv-script
38
+
39
+ # Or run without installing
40
+ uvx uv-script --list
41
+ ```
42
+
43
+ ## Quick start
44
+
45
+ Add a `[tool.uvs.scripts]` section to your `pyproject.toml`:
46
+
47
+ ```toml
48
+ [tool.uvs.scripts]
49
+ test = "pytest tests/ -v"
50
+ lint = "ruff check src/"
51
+ format = "ruff format src/"
52
+ check = ["lint", "test"]
53
+ ```
54
+
55
+ Then run:
56
+
57
+ ```bash
58
+ uvs test # runs: uv run pytest tests/ -v
59
+ uvs check # runs lint, then test (stops on first failure)
60
+ uvs --list # shows all available scripts
61
+ ```
62
+
63
+ ## Configuration
64
+
65
+ Scripts are defined under `[tool.uvs.scripts]` in `pyproject.toml`. Three formats are supported:
66
+
67
+ ### Simple command
68
+
69
+ A string value runs a single command through `uv run`:
70
+
71
+ ```toml
72
+ [tool.uvs.scripts]
73
+ test = "pytest tests/ -v"
74
+ lint = "ruff check ."
75
+ ```
76
+
77
+ ### Composite script
78
+
79
+ An array of strings runs multiple steps sequentially. Items can reference other script names or be raw commands. Execution stops on the first non-zero exit code.
80
+
81
+ ```toml
82
+ [tool.uvs.scripts]
83
+ lint = "ruff check ."
84
+ test = "pytest tests/"
85
+ check = ["lint", "test"]
86
+ full = ["ruff format --check .", "lint", "test"]
87
+ ```
88
+
89
+ ### Table with options
90
+
91
+ A table gives you control over environment variables and help text:
92
+
93
+ ```toml
94
+ [tool.uvs.scripts.serve]
95
+ cmd = "python -m flask run"
96
+ env = { FLASK_DEBUG = "1", FLASK_APP = "app.py" }
97
+ help = "Start the development server"
98
+
99
+ [tool.uvs.scripts.deploy]
100
+ cmd = "python scripts/deploy.py"
101
+ env = { ENV = "production" }
102
+ help = "Deploy to production"
103
+ ```
104
+
105
+ | Key | Type | Required | Description |
106
+ |--------|-------------------|----------|------------------------------------------|
107
+ | `cmd` | string | yes | The command to run |
108
+ | `env` | table of strings | no | Environment variables (overlays current) |
109
+ | `help` | string | no | Description shown in `--list` output |
110
+
111
+ ## Usage
112
+
113
+ ```
114
+ uvs [options] <script> [-- extra-args...]
115
+ ```
116
+
117
+ | Flag | Description |
118
+ |-------------------|-----------------------------------|
119
+ | `-l`, `--list` | List all available scripts |
120
+ | `-v`, `--verbose` | Print each command before running |
121
+ | `-V`, `--version` | Show version and exit |
122
+
123
+ ### Passing extra arguments
124
+
125
+ Use `--` to forward arguments to the underlying command:
126
+
127
+ ```bash
128
+ uvs test -- -k "test_parse" --no-header
129
+ # runs: uv run pytest tests/ -v -k test_parse --no-header
130
+ ```
131
+
132
+ For composite scripts, extra arguments are appended to the last command in the chain.
133
+
134
+ ### Running from subdirectories
135
+
136
+ `uvs` walks up from your current directory to find the nearest `pyproject.toml`, just like `uv` does. You can run `uvs test` from anywhere inside your project.
137
+
138
+ ## How it works
139
+
140
+ `uvs` is a thin orchestration layer. Every command is prefixed with `uv run`, which means:
141
+
142
+ 1. Your virtual environment is automatically activated
143
+ 2. Dependencies are synced from the lockfile if needed
144
+ 3. The correct Python version is used
145
+
146
+ There is no magic — `uvs test` is equivalent to typing `uv run pytest tests/ -v` yourself.
147
+
148
+ ## Development
149
+
150
+ This project uses `uvs` to manage its own scripts:
151
+
152
+ ```bash
153
+ git clone <repo-url> && cd uv-script
154
+ uv sync
155
+ uvs test # run tests
156
+ uvs lint # run linter
157
+ uvs check # lint + test
158
+ ```
159
+
160
+ ## License
161
+
162
+ MIT
@@ -0,0 +1,152 @@
1
+ # uv-script
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/uv-script)](https://pypi.org/project/uv-script/)
4
+
5
+ A lightweight, zero-dependency script runner for [uv](https://docs.astral.sh/uv/). Define project scripts in `pyproject.toml` and run them through `uv run`.
6
+
7
+ ```
8
+ $ uvs --list
9
+ check lint -> test
10
+ format ruff format src/
11
+ lint ruff check src/
12
+ test pytest tests/ -v
13
+ ```
14
+
15
+ ## Why?
16
+
17
+ uv is a fantastic package manager but has no built-in task runner. If you've been using [Hatch](https://hatch.pypa.io/) just for its scripts, or reaching for [Poe the Poet](https://poethepoet.natn.io/) / [Taskipy](https://github.com/taskipy/taskipy) to fill the gap, `uvs` offers a simpler alternative:
18
+
19
+ - **Zero runtime dependencies** — stdlib only (`tomllib`, `argparse`, `subprocess`)
20
+ - **uv-native** — every command runs through `uv run`, so your venv, lockfile, and Python version are always in sync
21
+ - **Familiar** — if you've used npm scripts or Hatch scripts, you already know how this works
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ # As a dev dependency in your project
27
+ uv add --dev uv-script
28
+
29
+ # Or run without installing
30
+ uvx uv-script --list
31
+ ```
32
+
33
+ ## Quick start
34
+
35
+ Add a `[tool.uvs.scripts]` section to your `pyproject.toml`:
36
+
37
+ ```toml
38
+ [tool.uvs.scripts]
39
+ test = "pytest tests/ -v"
40
+ lint = "ruff check src/"
41
+ format = "ruff format src/"
42
+ check = ["lint", "test"]
43
+ ```
44
+
45
+ Then run:
46
+
47
+ ```bash
48
+ uvs test # runs: uv run pytest tests/ -v
49
+ uvs check # runs lint, then test (stops on first failure)
50
+ uvs --list # shows all available scripts
51
+ ```
52
+
53
+ ## Configuration
54
+
55
+ Scripts are defined under `[tool.uvs.scripts]` in `pyproject.toml`. Three formats are supported:
56
+
57
+ ### Simple command
58
+
59
+ A string value runs a single command through `uv run`:
60
+
61
+ ```toml
62
+ [tool.uvs.scripts]
63
+ test = "pytest tests/ -v"
64
+ lint = "ruff check ."
65
+ ```
66
+
67
+ ### Composite script
68
+
69
+ An array of strings runs multiple steps sequentially. Items can reference other script names or be raw commands. Execution stops on the first non-zero exit code.
70
+
71
+ ```toml
72
+ [tool.uvs.scripts]
73
+ lint = "ruff check ."
74
+ test = "pytest tests/"
75
+ check = ["lint", "test"]
76
+ full = ["ruff format --check .", "lint", "test"]
77
+ ```
78
+
79
+ ### Table with options
80
+
81
+ A table gives you control over environment variables and help text:
82
+
83
+ ```toml
84
+ [tool.uvs.scripts.serve]
85
+ cmd = "python -m flask run"
86
+ env = { FLASK_DEBUG = "1", FLASK_APP = "app.py" }
87
+ help = "Start the development server"
88
+
89
+ [tool.uvs.scripts.deploy]
90
+ cmd = "python scripts/deploy.py"
91
+ env = { ENV = "production" }
92
+ help = "Deploy to production"
93
+ ```
94
+
95
+ | Key | Type | Required | Description |
96
+ |--------|-------------------|----------|------------------------------------------|
97
+ | `cmd` | string | yes | The command to run |
98
+ | `env` | table of strings | no | Environment variables (overlays current) |
99
+ | `help` | string | no | Description shown in `--list` output |
100
+
101
+ ## Usage
102
+
103
+ ```
104
+ uvs [options] <script> [-- extra-args...]
105
+ ```
106
+
107
+ | Flag | Description |
108
+ |-------------------|-----------------------------------|
109
+ | `-l`, `--list` | List all available scripts |
110
+ | `-v`, `--verbose` | Print each command before running |
111
+ | `-V`, `--version` | Show version and exit |
112
+
113
+ ### Passing extra arguments
114
+
115
+ Use `--` to forward arguments to the underlying command:
116
+
117
+ ```bash
118
+ uvs test -- -k "test_parse" --no-header
119
+ # runs: uv run pytest tests/ -v -k test_parse --no-header
120
+ ```
121
+
122
+ For composite scripts, extra arguments are appended to the last command in the chain.
123
+
124
+ ### Running from subdirectories
125
+
126
+ `uvs` walks up from your current directory to find the nearest `pyproject.toml`, just like `uv` does. You can run `uvs test` from anywhere inside your project.
127
+
128
+ ## How it works
129
+
130
+ `uvs` is a thin orchestration layer. Every command is prefixed with `uv run`, which means:
131
+
132
+ 1. Your virtual environment is automatically activated
133
+ 2. Dependencies are synced from the lockfile if needed
134
+ 3. The correct Python version is used
135
+
136
+ There is no magic — `uvs test` is equivalent to typing `uv run pytest tests/ -v` yourself.
137
+
138
+ ## Development
139
+
140
+ This project uses `uvs` to manage its own scripts:
141
+
142
+ ```bash
143
+ git clone <repo-url> && cd uv-script
144
+ uv sync
145
+ uvs test # run tests
146
+ uvs lint # run linter
147
+ uvs check # lint + test
148
+ ```
149
+
150
+ ## License
151
+
152
+ MIT
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "uv-script"
3
+ version = "0.1.5"
4
+ description = "A lightweight script runner for uv — define and run project scripts from pyproject.toml"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Felix Simkovic", email = "felixsimkovic@me.com" }
9
+ ]
10
+ requires-python = ">=3.12"
11
+ dependencies = []
12
+
13
+ [project.scripts]
14
+ uvs = "uv_script.cli:main"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.8.7,<0.9.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "pytest>=8.0",
23
+ "pytest-cov>=5.0",
24
+ "ruff>=0.9.0",
25
+ "ty>=0.0.15",
26
+ ]
27
+
28
+ [tool.ruff]
29
+ target-version = "py312"
30
+ line-length = 99
31
+
32
+ [tool.ruff.lint]
33
+ select = ["E", "F", "I", "UP", "B", "SIM"]
34
+
35
+ [tool.uvs.scripts]
36
+ test = "pytest tests/ -v"
37
+ lint = "ruff check src/"
38
+ format = "ruff format src/"
39
+ typecheck = "ty check src/"
40
+ check = ["lint", "typecheck", "test"]
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("uv-script")
@@ -0,0 +1,3 @@
1
+ from uv_script.cli import main
2
+
3
+ main()
@@ -0,0 +1,102 @@
1
+ """CLI entry point for uvs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from uv_script import __version__
9
+ from uv_script.config import ConfigError, ScriptDef, load_scripts
10
+ from uv_script.runner import run_script
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> None:
14
+ parser = argparse.ArgumentParser(
15
+ prog="uvs",
16
+ description="Run scripts defined in [tool.uvs.scripts] via uv run",
17
+ )
18
+ parser.add_argument(
19
+ "-V",
20
+ "--version",
21
+ action="version",
22
+ version=f"%(prog)s {__version__}",
23
+ )
24
+ parser.add_argument(
25
+ "-l",
26
+ "--list",
27
+ action="store_true",
28
+ help="List available scripts",
29
+ )
30
+ parser.add_argument(
31
+ "-v",
32
+ "--verbose",
33
+ action="store_true",
34
+ help="Print commands before executing",
35
+ )
36
+ parser.add_argument(
37
+ "script",
38
+ nargs="?",
39
+ metavar="SCRIPT",
40
+ help="Name of the script to run",
41
+ )
42
+ parser.add_argument(
43
+ "args",
44
+ nargs=argparse.REMAINDER,
45
+ metavar="...",
46
+ help="Additional arguments passed to the script",
47
+ )
48
+
49
+ parsed = parser.parse_args(argv)
50
+
51
+ try:
52
+ scripts = load_scripts()
53
+ except ConfigError as e:
54
+ print(f"uvs: {e}", file=sys.stderr)
55
+ sys.exit(1)
56
+
57
+ if parsed.list:
58
+ _print_list(scripts)
59
+ return
60
+
61
+ if not parsed.script:
62
+ parser.print_help()
63
+ sys.exit(1)
64
+
65
+ if parsed.script not in scripts:
66
+ print(f"uvs: unknown script '{parsed.script}'", file=sys.stderr)
67
+ print(f"Available scripts: {', '.join(sorted(scripts))}", file=sys.stderr)
68
+ sys.exit(1)
69
+
70
+ script = scripts[parsed.script]
71
+
72
+ # Strip leading '--' from extra args if present
73
+ extra_args = parsed.args
74
+ if extra_args and extra_args[0] == "--":
75
+ extra_args = extra_args[1:]
76
+
77
+ exit_code = run_script(
78
+ script,
79
+ all_scripts=scripts,
80
+ extra_args=extra_args or None,
81
+ verbose=parsed.verbose,
82
+ )
83
+ sys.exit(exit_code)
84
+
85
+
86
+ def _print_list(scripts: dict[str, ScriptDef]) -> None:
87
+ """Print a formatted list of available scripts."""
88
+ if not scripts:
89
+ print("No scripts defined.")
90
+ return
91
+
92
+ max_name = max(len(name) for name in scripts)
93
+
94
+ for name in sorted(scripts):
95
+ script = scripts[name]
96
+ help_text = script.help_text
97
+ if not help_text:
98
+ help_text = (
99
+ " -> ".join(script.commands) if script.is_composite else script.commands[0]
100
+ )
101
+
102
+ print(f" {name:<{max_name}} {help_text}")
@@ -0,0 +1,80 @@
1
+ """Load and parse script definitions from pyproject.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass
11
+ class ScriptDef:
12
+ """Normalized representation of a script definition."""
13
+
14
+ name: str
15
+ commands: list[str]
16
+ env: dict[str, str] = field(default_factory=dict)
17
+ help_text: str = ""
18
+ is_composite: bool = False
19
+
20
+
21
+ class ConfigError(Exception):
22
+ """Raised when pyproject.toml is missing or malformed."""
23
+
24
+
25
+ def find_pyproject(start: Path | None = None) -> Path:
26
+ """Walk up from start (default: cwd) to find pyproject.toml."""
27
+ current = start or Path.cwd()
28
+ for directory in [current, *current.parents]:
29
+ candidate = directory / "pyproject.toml"
30
+ if candidate.is_file():
31
+ return candidate
32
+ raise ConfigError("No pyproject.toml found in current directory or any parent")
33
+
34
+
35
+ def load_scripts(pyproject_path: Path | None = None) -> dict[str, ScriptDef]:
36
+ """Load and normalize all script definitions.
37
+
38
+ Returns dict mapping script name -> ScriptDef.
39
+ """
40
+ path = pyproject_path or find_pyproject()
41
+
42
+ with open(path, "rb") as f:
43
+ data = tomllib.load(f)
44
+
45
+ scripts_table = data.get("tool", {}).get("uvs", {}).get("scripts", {})
46
+ if not scripts_table:
47
+ raise ConfigError(f"No [tool.uvs.scripts] section found in {path}")
48
+
49
+ result: dict[str, ScriptDef] = {}
50
+ for name, value in scripts_table.items():
51
+ result[name] = _parse_script(name, value)
52
+ return result
53
+
54
+
55
+ def _parse_script(name: str, value: str | list | dict) -> ScriptDef:
56
+ """Parse a single script value into a ScriptDef."""
57
+ if isinstance(value, str):
58
+ return ScriptDef(name=name, commands=[value])
59
+
60
+ if isinstance(value, list):
61
+ if not all(isinstance(v, str) for v in value):
62
+ raise ConfigError(f"Script '{name}': array items must all be strings")
63
+ return ScriptDef(name=name, commands=value, is_composite=True)
64
+
65
+ if isinstance(value, dict):
66
+ cmd = value.get("cmd")
67
+ if not cmd or not isinstance(cmd, str):
68
+ raise ConfigError(f"Script '{name}': table form requires a 'cmd' string")
69
+ env = value.get("env", {})
70
+ if not isinstance(env, dict):
71
+ raise ConfigError(f"Script '{name}': 'env' must be a table of strings")
72
+ help_text = value.get("help", "")
73
+ return ScriptDef(
74
+ name=name,
75
+ commands=[cmd],
76
+ env={str(k): str(v) for k, v in env.items()},
77
+ help_text=help_text,
78
+ )
79
+
80
+ raise ConfigError(f"Script '{name}': value must be a string, array, or table")
@@ -0,0 +1,78 @@
1
+ """Execute scripts by delegating to uv run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ import sys
9
+
10
+ from uv_script.config import ConfigError, ScriptDef
11
+
12
+
13
+ def run_script(
14
+ script: ScriptDef,
15
+ all_scripts: dict[str, ScriptDef],
16
+ extra_args: list[str] | None = None,
17
+ verbose: bool = False,
18
+ ) -> int:
19
+ """Execute a script definition. Returns exit code (0 = success)."""
20
+ steps = resolve_steps(script, all_scripts)
21
+
22
+ for i, (cmd_str, env) in enumerate(steps):
23
+ if extra_args and i == len(steps) - 1:
24
+ cmd_str = cmd_str + " " + " ".join(shlex.quote(a) for a in extra_args)
25
+
26
+ exit_code = _exec_one(cmd_str, env, verbose)
27
+ if exit_code != 0:
28
+ return exit_code
29
+
30
+ return 0
31
+
32
+
33
+ def resolve_steps(
34
+ script: ScriptDef,
35
+ all_scripts: dict[str, ScriptDef],
36
+ _seen: set[str] | None = None,
37
+ ) -> list[tuple[str, dict[str, str]]]:
38
+ """Resolve a script into a flat list of (command, env) pairs.
39
+
40
+ Handles references to other scripts and detects cycles.
41
+ """
42
+ if _seen is None:
43
+ _seen = set()
44
+
45
+ if script.name in _seen:
46
+ raise ConfigError(f"Circular reference detected: {script.name}")
47
+ _seen.add(script.name)
48
+
49
+ if not script.is_composite:
50
+ return [(cmd, script.env) for cmd in script.commands]
51
+
52
+ result: list[tuple[str, dict[str, str]]] = []
53
+ for item in script.commands:
54
+ if item in all_scripts:
55
+ referenced = all_scripts[item]
56
+ result.extend(resolve_steps(referenced, all_scripts, _seen.copy()))
57
+ else:
58
+ result.append((item, script.env))
59
+
60
+ return result
61
+
62
+
63
+ def _exec_one(cmd_str: str, env: dict[str, str], verbose: bool) -> int:
64
+ """Execute a single command string via uv run."""
65
+ parts = shlex.split(cmd_str)
66
+ full_cmd = ["uv", "run"] + parts
67
+
68
+ if verbose:
69
+ env_prefix = " ".join(f"{k}={shlex.quote(v)}" for k, v in env.items())
70
+ display = f"{env_prefix} {' '.join(full_cmd)}".strip()
71
+ print(f"$ {display}", file=sys.stderr)
72
+
73
+ run_env = None
74
+ if env:
75
+ run_env = {**os.environ, **env}
76
+
77
+ result = subprocess.run(full_cmd, env=run_env)
78
+ return result.returncode