uv-toolbox 0.1.0__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.
- uv_toolbox-0.1.0/PKG-INFO +125 -0
- uv_toolbox-0.1.0/README.md +98 -0
- uv_toolbox-0.1.0/pyproject.toml +65 -0
- uv_toolbox-0.1.0/uv_toolbox/__init__.py +3 -0
- uv_toolbox-0.1.0/uv_toolbox/cli.py +166 -0
- uv_toolbox-0.1.0/uv_toolbox/errors.py +111 -0
- uv_toolbox-0.1.0/uv_toolbox/process.py +80 -0
- uv_toolbox-0.1.0/uv_toolbox/settings.py +465 -0
- uv_toolbox-0.1.0/uv_toolbox/shims.py +200 -0
- uv_toolbox-0.1.0/uv_toolbox/utils.py +28 -0
- uv_toolbox-0.1.0/uv_toolbox/uv_helpers.py +93 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uv-toolbox
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Manage Python tool environments with declarative configuration and content-addressed storage.
|
|
5
|
+
Keywords: uv,virtual-environment,venv,tools,python,dependency-management,cli,content-addressed
|
|
6
|
+
Author: James Trousdale
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Topic :: System :: Systems Administration
|
|
17
|
+
Requires-Dist: typer==0.21.1
|
|
18
|
+
Requires-Dist: pydantic>=2.7.0,<3
|
|
19
|
+
Requires-Dist: pydantic-settings>=2.7.0,<3
|
|
20
|
+
Requires-Dist: pyyaml>=6,<7
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Project-URL: Homepage, https://github.com/hotdog-werx/uv-toolbox
|
|
23
|
+
Project-URL: Repository, https://github.com/hotdog-werx/uv-toolbox
|
|
24
|
+
Project-URL: Bug Tracker, https://github.com/hotdog-werx/uv-toolbox/issues
|
|
25
|
+
Project-URL: Changelog, https://github.com/hotdog-werx/uv-toolbox/blob/master/CHANGELOG.md
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# uv-toolbox
|
|
29
|
+
|
|
30
|
+
[](https://github.com/hotdog-werx/uv-toolbox/actions/workflows/ci.yml)
|
|
31
|
+
[](https://pypi.org/project/uv-toolbox/)
|
|
32
|
+
[](https://www.python.org/downloads/)
|
|
33
|
+
[](https://opensource.org/licenses/MIT)
|
|
34
|
+
[](https://codecov.io/gh/hotdog-werx/uv-toolbox)
|
|
35
|
+
|
|
36
|
+
`uv-toolbox` is a CLI tool for managing Python tool environments. It will help
|
|
37
|
+
you create multiple virtual environments and manage their dependencies through a
|
|
38
|
+
declarative configuration file.
|
|
39
|
+
|
|
40
|
+
Here is an example in YAML format:
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
environments:
|
|
44
|
+
- name: env1
|
|
45
|
+
requirements: |
|
|
46
|
+
ruff==0.13.0
|
|
47
|
+
black
|
|
48
|
+
executables: [ruff, black]
|
|
49
|
+
- name: env2
|
|
50
|
+
requirements: |
|
|
51
|
+
isort
|
|
52
|
+
flake8
|
|
53
|
+
executables: [isort, flake8]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Configuration Options
|
|
57
|
+
|
|
58
|
+
**Virtual Environment Location:**
|
|
59
|
+
|
|
60
|
+
By default, virtual environments are stored in `~/.cache/uv-toolbox/` using
|
|
61
|
+
**content-addressed storage**. This means:
|
|
62
|
+
|
|
63
|
+
- **Automatic deduplication**: Identical requirements across projects share the
|
|
64
|
+
same venv
|
|
65
|
+
- **No naming conflicts**: Venvs are organized by content hash, not names
|
|
66
|
+
- **Works from subdirectories**: Run commands from anywhere in your project
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
# Default: centralized, content-addressed storage
|
|
70
|
+
# (no venv_path needed - defaults to ~/.cache/uv-toolbox)
|
|
71
|
+
environments:
|
|
72
|
+
- name: formatting
|
|
73
|
+
requirements: ruff==0.13.0
|
|
74
|
+
- name: testing
|
|
75
|
+
requirements: pytest==8.0.0
|
|
76
|
+
|
|
77
|
+
# Optional: local storage (per-project)
|
|
78
|
+
venv_path: .uv-toolbox
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**How it works:**
|
|
82
|
+
|
|
83
|
+
- Each environment's venv location is determined by hashing its requirements
|
|
84
|
+
- Projects with identical requirements automatically share the same venv
|
|
85
|
+
- Config files are discovered by walking up the directory tree
|
|
86
|
+
|
|
87
|
+
**Executables:**
|
|
88
|
+
|
|
89
|
+
The `executables` field controls which tools are exposed via shims:
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
environments:
|
|
93
|
+
- name: formatting
|
|
94
|
+
requirements: ruff==0.13.0
|
|
95
|
+
executables: [ruff] # Only ruff will be available in PATH via shims
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **Optional**: Only needed if you want to use `uv-toolbox shim`
|
|
99
|
+
- **Explicit control**: List exactly which executables to expose
|
|
100
|
+
- **Prevents PATH pollution**: Python/pip from the venv won't be added to PATH
|
|
101
|
+
|
|
102
|
+
## Usage
|
|
103
|
+
|
|
104
|
+
Install environments:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
uv-toolbox install
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Run a command inside an environment (uses the configured default if set,
|
|
111
|
+
otherwise pass `--env`):
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
uv-toolbox exec --env env1 -- ruff --version
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Add shim scripts to your PATH for direct tool access:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
eval "$(uv-toolbox shim)"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
This creates wrapper scripts for executables listed in the `executables` field
|
|
124
|
+
of each environment. Only explicitly listed executables are exposed, preventing
|
|
125
|
+
Python/pip from polluting your PATH.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# uv-toolbox
|
|
2
|
+
|
|
3
|
+
[](https://github.com/hotdog-werx/uv-toolbox/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/uv-toolbox/)
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://codecov.io/gh/hotdog-werx/uv-toolbox)
|
|
8
|
+
|
|
9
|
+
`uv-toolbox` is a CLI tool for managing Python tool environments. It will help
|
|
10
|
+
you create multiple virtual environments and manage their dependencies through a
|
|
11
|
+
declarative configuration file.
|
|
12
|
+
|
|
13
|
+
Here is an example in YAML format:
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
environments:
|
|
17
|
+
- name: env1
|
|
18
|
+
requirements: |
|
|
19
|
+
ruff==0.13.0
|
|
20
|
+
black
|
|
21
|
+
executables: [ruff, black]
|
|
22
|
+
- name: env2
|
|
23
|
+
requirements: |
|
|
24
|
+
isort
|
|
25
|
+
flake8
|
|
26
|
+
executables: [isort, flake8]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Configuration Options
|
|
30
|
+
|
|
31
|
+
**Virtual Environment Location:**
|
|
32
|
+
|
|
33
|
+
By default, virtual environments are stored in `~/.cache/uv-toolbox/` using
|
|
34
|
+
**content-addressed storage**. This means:
|
|
35
|
+
|
|
36
|
+
- **Automatic deduplication**: Identical requirements across projects share the
|
|
37
|
+
same venv
|
|
38
|
+
- **No naming conflicts**: Venvs are organized by content hash, not names
|
|
39
|
+
- **Works from subdirectories**: Run commands from anywhere in your project
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
# Default: centralized, content-addressed storage
|
|
43
|
+
# (no venv_path needed - defaults to ~/.cache/uv-toolbox)
|
|
44
|
+
environments:
|
|
45
|
+
- name: formatting
|
|
46
|
+
requirements: ruff==0.13.0
|
|
47
|
+
- name: testing
|
|
48
|
+
requirements: pytest==8.0.0
|
|
49
|
+
|
|
50
|
+
# Optional: local storage (per-project)
|
|
51
|
+
venv_path: .uv-toolbox
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**How it works:**
|
|
55
|
+
|
|
56
|
+
- Each environment's venv location is determined by hashing its requirements
|
|
57
|
+
- Projects with identical requirements automatically share the same venv
|
|
58
|
+
- Config files are discovered by walking up the directory tree
|
|
59
|
+
|
|
60
|
+
**Executables:**
|
|
61
|
+
|
|
62
|
+
The `executables` field controls which tools are exposed via shims:
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
environments:
|
|
66
|
+
- name: formatting
|
|
67
|
+
requirements: ruff==0.13.0
|
|
68
|
+
executables: [ruff] # Only ruff will be available in PATH via shims
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- **Optional**: Only needed if you want to use `uv-toolbox shim`
|
|
72
|
+
- **Explicit control**: List exactly which executables to expose
|
|
73
|
+
- **Prevents PATH pollution**: Python/pip from the venv won't be added to PATH
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
Install environments:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
uv-toolbox install
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Run a command inside an environment (uses the configured default if set,
|
|
84
|
+
otherwise pass `--env`):
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv-toolbox exec --env env1 -- ruff --version
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Add shim scripts to your PATH for direct tool access:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
eval "$(uv-toolbox shim)"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This creates wrapper scripts for executables listed in the `executables` field
|
|
97
|
+
of each environment. Only explicitly listed executables are exposed, preventing
|
|
98
|
+
Python/pip from polluting your PATH.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "uv-toolbox"
|
|
3
|
+
description = "Manage Python tool environments with declarative configuration and content-addressed storage."
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
version = "0.1.0"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "James Trousdale" }]
|
|
9
|
+
keywords = [
|
|
10
|
+
"uv",
|
|
11
|
+
"virtual-environment",
|
|
12
|
+
"venv",
|
|
13
|
+
"tools",
|
|
14
|
+
"python",
|
|
15
|
+
"dependency-management",
|
|
16
|
+
"cli",
|
|
17
|
+
"content-addressed",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
"Topic :: System :: Systems Administration",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
dependencies = [
|
|
32
|
+
"typer ==0.21.1",
|
|
33
|
+
"pydantic >=2.7.0, <3",
|
|
34
|
+
"pydantic-settings >=2.7.0, <3",
|
|
35
|
+
'PyYAML >=6,<7',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/hotdog-werx/uv-toolbox"
|
|
40
|
+
Repository = "https://github.com/hotdog-werx/uv-toolbox"
|
|
41
|
+
"Bug Tracker" = "https://github.com/hotdog-werx/uv-toolbox/issues"
|
|
42
|
+
Changelog = "https://github.com/hotdog-werx/uv-toolbox/blob/master/CHANGELOG.md"
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
uv-toolbox = "uv_toolbox.cli:main"
|
|
46
|
+
uvtb = "uv_toolbox.cli:main"
|
|
47
|
+
|
|
48
|
+
[dependency-groups]
|
|
49
|
+
dev = ["pytest>=8.0.0", "pytest-mock>=3.12.0", "pytest-cov>=4.0.0"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
extend = "./.pkglink/.codeguide/configs/ruff.toml"
|
|
53
|
+
|
|
54
|
+
[tool.releez]
|
|
55
|
+
create-pr = true
|
|
56
|
+
run-changelog-format = true
|
|
57
|
+
hooks = { changelog-format = ["mise", "exec", "--", "poe", "format-dprint", "{changelog}"] }
|
|
58
|
+
|
|
59
|
+
[build-system]
|
|
60
|
+
requires = ["uv_build>=0.9.27,<0.10.0"]
|
|
61
|
+
build-backend = "uv_build"
|
|
62
|
+
|
|
63
|
+
[tool.uv.build-backend]
|
|
64
|
+
module-name = ["uv_toolbox"]
|
|
65
|
+
module-root = ""
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from uv_toolbox.errors import CommandDelimiterRequiredError, UvToolboxError
|
|
9
|
+
from uv_toolbox.process import run_checked
|
|
10
|
+
from uv_toolbox.settings import UvToolboxSettings
|
|
11
|
+
from uv_toolbox.shims import create_shims
|
|
12
|
+
from uv_toolbox.utils import _filter_nulls
|
|
13
|
+
from uv_toolbox.uv_helpers import initialize_virtualenv
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help='CLI tool for managing UV tool environments.')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.callback()
|
|
19
|
+
def _root(
|
|
20
|
+
ctx: typer.Context,
|
|
21
|
+
config_file: Annotated[
|
|
22
|
+
Path | None,
|
|
23
|
+
typer.Option(
|
|
24
|
+
...,
|
|
25
|
+
'--config',
|
|
26
|
+
'-c',
|
|
27
|
+
help='Path to a uv-toolbox config file.',
|
|
28
|
+
),
|
|
29
|
+
] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""UV Toolbox CLI."""
|
|
32
|
+
ctx.obj = _filter_nulls({'config_file': config_file})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command(name='install')
|
|
36
|
+
def install(
|
|
37
|
+
ctx: typer.Context,
|
|
38
|
+
*,
|
|
39
|
+
venv_path: Annotated[
|
|
40
|
+
Path | None,
|
|
41
|
+
typer.Option(
|
|
42
|
+
...,
|
|
43
|
+
'--venv-path',
|
|
44
|
+
help='Path to the directory where virtual environments are stored.',
|
|
45
|
+
),
|
|
46
|
+
] = None,
|
|
47
|
+
clear: Annotated[
|
|
48
|
+
bool,
|
|
49
|
+
typer.Option(
|
|
50
|
+
...,
|
|
51
|
+
'--clear',
|
|
52
|
+
'-c',
|
|
53
|
+
help='Clear and recreate the virtual environments.',
|
|
54
|
+
),
|
|
55
|
+
] = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Install UV tool environments."""
|
|
58
|
+
settings = UvToolboxSettings.from_context(ctx, venv_path=venv_path)
|
|
59
|
+
for env in settings.environments:
|
|
60
|
+
try:
|
|
61
|
+
initialize_virtualenv(env=env, settings=settings, clear=clear)
|
|
62
|
+
except UvToolboxError as exc:
|
|
63
|
+
typer.secho(str(exc), err=True, fg=typer.colors.RED)
|
|
64
|
+
raise typer.Exit(code=1) from exc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command(name='exec')
|
|
68
|
+
def exec_(
|
|
69
|
+
ctx: typer.Context,
|
|
70
|
+
*,
|
|
71
|
+
command: Annotated[
|
|
72
|
+
list[str],
|
|
73
|
+
typer.Argument(
|
|
74
|
+
...,
|
|
75
|
+
help='Command to run in the environment.',
|
|
76
|
+
),
|
|
77
|
+
],
|
|
78
|
+
env_name: Annotated[
|
|
79
|
+
str | None,
|
|
80
|
+
typer.Option(
|
|
81
|
+
...,
|
|
82
|
+
'--env',
|
|
83
|
+
'-e',
|
|
84
|
+
help='Environment name (required when multiple environments exist).',
|
|
85
|
+
),
|
|
86
|
+
] = None,
|
|
87
|
+
clear: Annotated[
|
|
88
|
+
bool,
|
|
89
|
+
typer.Option(
|
|
90
|
+
...,
|
|
91
|
+
'--clear',
|
|
92
|
+
'-c',
|
|
93
|
+
help='Clear and recreate the virtual environment.',
|
|
94
|
+
),
|
|
95
|
+
] = False,
|
|
96
|
+
venv_path: Annotated[
|
|
97
|
+
Path | None,
|
|
98
|
+
typer.Option(
|
|
99
|
+
...,
|
|
100
|
+
'--venv-path',
|
|
101
|
+
help='Path to the directory where virtual environments are stored.',
|
|
102
|
+
),
|
|
103
|
+
] = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Execute a command within a UV tool environment."""
|
|
106
|
+
if '--' not in sys.argv:
|
|
107
|
+
raise CommandDelimiterRequiredError
|
|
108
|
+
settings = UvToolboxSettings.from_context(ctx, venv_path=venv_path)
|
|
109
|
+
try:
|
|
110
|
+
env = settings.select_environment(
|
|
111
|
+
env_name=env_name,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if not env.venv_path(settings=settings).exists() or clear:
|
|
115
|
+
initialize_virtualenv(
|
|
116
|
+
env=env,
|
|
117
|
+
settings=settings,
|
|
118
|
+
clear=clear,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Use uv run with --no-project to prevent automatic project detection
|
|
122
|
+
# VIRTUAL_ENV is set via extra_env to specify which venv to use
|
|
123
|
+
run_checked(
|
|
124
|
+
args=['uv', 'run', '--no-project', '--', *command],
|
|
125
|
+
capture_stdout=False,
|
|
126
|
+
capture_stderr=False,
|
|
127
|
+
extra_env=env.process_env(settings=settings),
|
|
128
|
+
show_command=settings.show_commands,
|
|
129
|
+
)
|
|
130
|
+
except UvToolboxError as exc:
|
|
131
|
+
typer.secho(str(exc), err=True, fg=typer.colors.RED)
|
|
132
|
+
raise typer.Exit(code=1) from exc
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command(name='shim')
|
|
136
|
+
def shim(
|
|
137
|
+
ctx: typer.Context,
|
|
138
|
+
venv_path: Annotated[
|
|
139
|
+
Path | None,
|
|
140
|
+
typer.Option(
|
|
141
|
+
...,
|
|
142
|
+
'--venv-path',
|
|
143
|
+
help='Path to the directory where virtual environments are stored.',
|
|
144
|
+
),
|
|
145
|
+
] = None,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Create shim scripts for tools and emit shell code to add shims to PATH."""
|
|
148
|
+
settings = UvToolboxSettings.from_context(ctx, venv_path=venv_path)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
shim_dirs = create_shims(settings=settings)
|
|
152
|
+
if shim_dirs:
|
|
153
|
+
# Join all shim directories in config order
|
|
154
|
+
shim_path = os.pathsep.join(str(d) for d in shim_dirs)
|
|
155
|
+
typer.echo(f'export PATH="{shim_path}{os.pathsep}$PATH"')
|
|
156
|
+
else:
|
|
157
|
+
# No shims created (no venvs or no executables)
|
|
158
|
+
typer.echo('# No shims to add to PATH')
|
|
159
|
+
except UvToolboxError as exc:
|
|
160
|
+
typer.secho(str(exc), err=True, fg=typer.colors.RED)
|
|
161
|
+
raise typer.Exit(code=1) from exc
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main() -> None:
|
|
165
|
+
"""Main entry point for the CLI."""
|
|
166
|
+
app()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
if typing.TYPE_CHECKING:
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UvToolboxError(RuntimeError):
|
|
11
|
+
"""Base error for uv-toolbox.."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MissingCliError(UvToolboxError):
|
|
15
|
+
"""Raised when a required CLI executable is missing."""
|
|
16
|
+
|
|
17
|
+
cli_names: list[str]
|
|
18
|
+
|
|
19
|
+
def __init__(self, cli_names: str | Sequence[str]) -> None:
|
|
20
|
+
self.cli_names = [cli_names] if isinstance(cli_names, str) else list(cli_names)
|
|
21
|
+
if len(self.cli_names) == 1:
|
|
22
|
+
message = f'Required CLI {self.cli_names[0]!r} is not installed or not on PATH.'
|
|
23
|
+
else:
|
|
24
|
+
joined = ', '.join(repr(name) for name in self.cli_names)
|
|
25
|
+
message = f'Required CLIs {joined} are not installed or not on PATH.'
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExternalCommandError(UvToolboxError):
|
|
30
|
+
"""Raised when an external command returns a non-zero status."""
|
|
31
|
+
|
|
32
|
+
cmd_args: list[str]
|
|
33
|
+
returncode: int
|
|
34
|
+
stderr: str
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
cmd_args: Sequence[str],
|
|
40
|
+
returncode: int,
|
|
41
|
+
stderr: str | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.cmd_args = list(cmd_args)
|
|
44
|
+
self.returncode = returncode
|
|
45
|
+
self.stderr = (stderr or '').strip()
|
|
46
|
+
|
|
47
|
+
cmd = ' '.join(self.cmd_args)
|
|
48
|
+
message = f'Command failed ({self.returncode}): {cmd}'
|
|
49
|
+
if self.stderr:
|
|
50
|
+
message = f'{message}\n{self.stderr}'
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EnvironmentNotFoundError(UvToolboxError):
|
|
55
|
+
"""Raised when the requested environment name does not exist."""
|
|
56
|
+
|
|
57
|
+
env_name: str
|
|
58
|
+
available: list[str]
|
|
59
|
+
|
|
60
|
+
def __init__(self, env_name: str, available: list[str]) -> None:
|
|
61
|
+
self.env_name = env_name
|
|
62
|
+
self.available = available
|
|
63
|
+
available_list = ', '.join(available) or '<none>'
|
|
64
|
+
super().__init__(
|
|
65
|
+
f'Environment {env_name!r} not found. Available environments: {available_list}.',
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MultipleEnvironmentsError(UvToolboxError):
|
|
70
|
+
"""Raised when multiple environments exist but none was selected."""
|
|
71
|
+
|
|
72
|
+
available: list[str]
|
|
73
|
+
|
|
74
|
+
def __init__(self, available: list[str]) -> None:
|
|
75
|
+
self.available = available
|
|
76
|
+
available_list = ', '.join(available) or '<none>'
|
|
77
|
+
super().__init__(
|
|
78
|
+
f'Multiple environments configured; use --env to select one. Available environments: {available_list}.',
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CommandDelimiterRequiredError(UvToolboxError):
|
|
83
|
+
"""Raised when an exec command is missing the required `--` delimiter."""
|
|
84
|
+
|
|
85
|
+
def __init__(self) -> None:
|
|
86
|
+
super().__init__('Commands must be passed after `--`.')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ConfigFileNotFoundError(UvToolboxError):
|
|
90
|
+
"""Raised when an explicit config file path does not exist."""
|
|
91
|
+
|
|
92
|
+
config_file: Path
|
|
93
|
+
|
|
94
|
+
def __init__(self, config_file: Path) -> None:
|
|
95
|
+
self.config_file = config_file
|
|
96
|
+
super().__init__(f'Configuration file not found: {config_file}')
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MissingConfigFileError(UvToolboxError):
|
|
100
|
+
"""Raised when no configuration file can be found."""
|
|
101
|
+
|
|
102
|
+
searched_files: list[Path]
|
|
103
|
+
|
|
104
|
+
def __init__(self, searched_files: list[Path]) -> None:
|
|
105
|
+
self.searched_files = searched_files
|
|
106
|
+
searched = ', '.join(str(path) for path in searched_files)
|
|
107
|
+
super().__init__(
|
|
108
|
+
'No configuration file found. '
|
|
109
|
+
f'Searched: {searched}. '
|
|
110
|
+
'Use --config or UV_TOOLBOX_CONFIG_FILE to specify a file.',
|
|
111
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from uv_toolbox.errors import ExternalCommandError, MissingCliError
|
|
10
|
+
|
|
11
|
+
if typing.TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _print_command(args: Sequence[str]) -> None:
|
|
17
|
+
"""Print a command in a nice format to stderr.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
args: The command and arguments to print.
|
|
21
|
+
"""
|
|
22
|
+
cmd = ' '.join(args)
|
|
23
|
+
typer.secho(f'▶ {cmd}', fg=typer.colors.BRIGHT_BLACK, err=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_checked( # noqa: PLR0913
|
|
27
|
+
args: Sequence[str],
|
|
28
|
+
*,
|
|
29
|
+
cwd: Path | None = None,
|
|
30
|
+
capture_stdout: bool = True,
|
|
31
|
+
capture_stderr: bool = True,
|
|
32
|
+
extra_env: dict[str, str] | None = None,
|
|
33
|
+
show_command: bool = False,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Run a command and raise a UvToolboxError on failure.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
args: The command and arguments to execute.
|
|
39
|
+
cwd: Optional working directory for the command.
|
|
40
|
+
capture_stdout: If false, stdout is not captured.
|
|
41
|
+
capture_stderr: If false, stderr is not captured.
|
|
42
|
+
extra_env: Additional environment variables to set for the command.
|
|
43
|
+
show_command: If true, print the command before executing.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The stripped stdout of the command.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
MissingCliError: If the executable is not found.
|
|
50
|
+
ExternalCommandError: If the command exits non-zero.
|
|
51
|
+
"""
|
|
52
|
+
if show_command:
|
|
53
|
+
_print_command(args)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
stdout = subprocess.PIPE if capture_stdout else None
|
|
57
|
+
stderr = subprocess.PIPE if capture_stderr else None
|
|
58
|
+
res = subprocess.run( # noqa: S603
|
|
59
|
+
list(args),
|
|
60
|
+
cwd=cwd,
|
|
61
|
+
check=True,
|
|
62
|
+
text=True,
|
|
63
|
+
stdout=stdout,
|
|
64
|
+
stderr=stderr,
|
|
65
|
+
env={
|
|
66
|
+
**os.environ,
|
|
67
|
+
**(extra_env or {}),
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
except FileNotFoundError as exc:
|
|
71
|
+
raise MissingCliError(args[0]) from exc
|
|
72
|
+
except subprocess.CalledProcessError as exc:
|
|
73
|
+
stderr = (exc.stderr or '').strip() if capture_stderr else ''
|
|
74
|
+
raise ExternalCommandError(
|
|
75
|
+
cmd_args=args,
|
|
76
|
+
returncode=exc.returncode,
|
|
77
|
+
stderr=stderr,
|
|
78
|
+
) from exc
|
|
79
|
+
|
|
80
|
+
return (res.stdout or '').strip() if capture_stdout else ''
|