uv-toolbox 0.1.0__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_toolbox/__init__.py +3 -0
- uv_toolbox/cli.py +166 -0
- uv_toolbox/errors.py +111 -0
- uv_toolbox/process.py +80 -0
- uv_toolbox/settings.py +465 -0
- uv_toolbox/shims.py +200 -0
- uv_toolbox/utils.py +28 -0
- uv_toolbox/uv_helpers.py +93 -0
- uv_toolbox-0.1.0.dist-info/METADATA +125 -0
- uv_toolbox-0.1.0.dist-info/RECORD +12 -0
- uv_toolbox-0.1.0.dist-info/WHEEL +4 -0
- uv_toolbox-0.1.0.dist-info/entry_points.txt +4 -0
uv_toolbox/__init__.py
ADDED
uv_toolbox/cli.py
ADDED
|
@@ -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()
|
uv_toolbox/errors.py
ADDED
|
@@ -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
|
+
)
|
uv_toolbox/process.py
ADDED
|
@@ -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 ''
|
uv_toolbox/settings.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import tomllib
|
|
6
|
+
import typing
|
|
7
|
+
import warnings
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from os.path import expandvars
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pydantic import (
|
|
13
|
+
AliasChoices,
|
|
14
|
+
AliasGenerator,
|
|
15
|
+
BaseModel,
|
|
16
|
+
ConfigDict,
|
|
17
|
+
Field,
|
|
18
|
+
model_validator,
|
|
19
|
+
)
|
|
20
|
+
from pydantic_settings import (
|
|
21
|
+
BaseSettings,
|
|
22
|
+
InitSettingsSource,
|
|
23
|
+
JsonConfigSettingsSource,
|
|
24
|
+
PydanticBaseSettingsSource,
|
|
25
|
+
PyprojectTomlConfigSettingsSource,
|
|
26
|
+
SettingsConfigDict,
|
|
27
|
+
TomlConfigSettingsSource,
|
|
28
|
+
YamlConfigSettingsSource,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from uv_toolbox.errors import (
|
|
32
|
+
ConfigFileNotFoundError,
|
|
33
|
+
EnvironmentNotFoundError,
|
|
34
|
+
MissingConfigFileError,
|
|
35
|
+
MultipleEnvironmentsError,
|
|
36
|
+
)
|
|
37
|
+
from uv_toolbox.utils import _filter_nulls
|
|
38
|
+
|
|
39
|
+
if typing.TYPE_CHECKING:
|
|
40
|
+
from collections.abc import Iterator
|
|
41
|
+
|
|
42
|
+
import typer
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _to_kebab(name: str) -> str:
|
|
46
|
+
"""Convert snake_case to kebab-case."""
|
|
47
|
+
return name.replace('_', '-')
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validation_alias(name: str) -> AliasChoices:
|
|
51
|
+
"""Accept both snake_case and kebab-case for settings keys.
|
|
52
|
+
|
|
53
|
+
`AliasChoices` order matters: we list the snake_case field name first so if both
|
|
54
|
+
variants are present (e.g. env var + pyproject), the env var wins.
|
|
55
|
+
"""
|
|
56
|
+
return AliasChoices(name, _to_kebab(name))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_ALIASES = AliasGenerator(
|
|
60
|
+
validation_alias=_validation_alias,
|
|
61
|
+
serialization_alias=_to_kebab,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class UvToolboxEnvironment(BaseModel):
|
|
66
|
+
"""Definition of a UV toolbox environment.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
requirements: A pip requirements string (mutually exclusive with
|
|
70
|
+
requirements_file).
|
|
71
|
+
requirements_file: A path to a pip requirements file (mutually
|
|
72
|
+
exclusive with requirements).
|
|
73
|
+
executables: List of executable names to create shims for. If not
|
|
74
|
+
specified, no shims will be created for this environment.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
requirements: str | None = None
|
|
79
|
+
requirements_file: Path | None = None
|
|
80
|
+
environment: dict[str, str] = {}
|
|
81
|
+
executables: list[str] = []
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(
|
|
84
|
+
alias_generator=_ALIASES,
|
|
85
|
+
populate_by_name=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@model_validator(mode='after')
|
|
89
|
+
def check_requirements(self) -> UvToolboxEnvironment:
|
|
90
|
+
"""Validate that only one of requirements or requirements_file is set."""
|
|
91
|
+
if not (bool(self.requirements) ^ bool(self.requirements_file)):
|
|
92
|
+
msg = 'Exactly one of requirements or requirements_file must be set.'
|
|
93
|
+
raise ValueError(msg)
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _normalize_requirements(req: str) -> str:
|
|
98
|
+
"""Normalize requirements string for consistent hashing.
|
|
99
|
+
|
|
100
|
+
- Removes comments and blank lines
|
|
101
|
+
- Strips whitespace
|
|
102
|
+
- Sorts lines alphabetically for consistency
|
|
103
|
+
"""
|
|
104
|
+
lines = [line.strip() for line in req.strip().split('\n')]
|
|
105
|
+
# Remove empty lines and comments
|
|
106
|
+
lines = [line for line in lines if line and not line.startswith('#')]
|
|
107
|
+
# Sort for consistency
|
|
108
|
+
return '\n'.join(sorted(lines))
|
|
109
|
+
|
|
110
|
+
def _get_requirements_hash(self) -> str:
|
|
111
|
+
"""Generate a hash of the environment's requirements.
|
|
112
|
+
|
|
113
|
+
Returns a 12-character hex string based on the normalized requirements.
|
|
114
|
+
"""
|
|
115
|
+
if self.requirements:
|
|
116
|
+
normalized = self._normalize_requirements(self.requirements)
|
|
117
|
+
else:
|
|
118
|
+
# Read and normalize requirements file
|
|
119
|
+
if self.requirements_file is None: # pragma: no cover
|
|
120
|
+
# Impossible: check_requirements validator ensures one is set
|
|
121
|
+
msg = 'Either requirements or requirements_file must be set'
|
|
122
|
+
raise ValueError(msg) # pragma: no cover
|
|
123
|
+
normalized = self._normalize_requirements(
|
|
124
|
+
self.requirements_file.read_text(),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Use SHA-256 for hashing (truncated to 12 chars for readability)
|
|
128
|
+
return hashlib.sha256(normalized.encode()).hexdigest()[:12]
|
|
129
|
+
|
|
130
|
+
def venv_path(self, settings: UvToolboxSettings) -> Path:
|
|
131
|
+
"""Get the path to the virtual environment for this environment.
|
|
132
|
+
|
|
133
|
+
Uses content-based addressing: the venv location is determined by
|
|
134
|
+
hashing the normalized requirements, ensuring identical requirements
|
|
135
|
+
share the same venv across projects.
|
|
136
|
+
"""
|
|
137
|
+
req_hash = self._get_requirements_hash()
|
|
138
|
+
return settings.resolved_venv_path / req_hash
|
|
139
|
+
|
|
140
|
+
def process_env(self, settings: UvToolboxSettings) -> dict[str, str]:
|
|
141
|
+
"""Environment variables for processes run in this environment."""
|
|
142
|
+
return {
|
|
143
|
+
**{k: expandvars(v) for k, v in self.environment.items()},
|
|
144
|
+
'VIRTUAL_ENV': str(self.venv_path(settings=settings)),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class UvToolboxSettings(BaseSettings):
|
|
149
|
+
"""Settings loaded from config files.
|
|
150
|
+
|
|
151
|
+
Precedence (highest first):
|
|
152
|
+
1. CLI args (passed to initialization)
|
|
153
|
+
2. Environment variables (with `UV_TOOLBOX_` prefix)
|
|
154
|
+
3. Configuration files, in order:
|
|
155
|
+
1. uv-toolbox.yaml
|
|
156
|
+
2. uv-toolbox.json
|
|
157
|
+
3. uv-toolbox.toml
|
|
158
|
+
4. pyproject.toml ([tool.uv-toolbox])
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
config_file: Path | None = None
|
|
162
|
+
venv_path: Path = Path.home() / '.cache' / 'uv-toolbox'
|
|
163
|
+
default_environment: str | None = None
|
|
164
|
+
show_commands: bool = False
|
|
165
|
+
environments: typing.Annotated[
|
|
166
|
+
list[UvToolboxEnvironment],
|
|
167
|
+
Field(min_length=1),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
model_config = SettingsConfigDict(
|
|
171
|
+
env_prefix='UV_TOOLBOX_',
|
|
172
|
+
env_nested_delimiter='__',
|
|
173
|
+
extra='forbid',
|
|
174
|
+
pyproject_toml_table_header=('tool', 'uv-toolbox'),
|
|
175
|
+
alias_generator=_ALIASES,
|
|
176
|
+
populate_by_name=True,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def resolved_venv_path(self) -> Path:
|
|
181
|
+
"""Resolve the venv path.
|
|
182
|
+
|
|
183
|
+
Behavior:
|
|
184
|
+
- Relative paths: resolved relative to config file location
|
|
185
|
+
- Absolute paths: used as-is
|
|
186
|
+
|
|
187
|
+
Individual venvs are stored in content-addressed subdirectories based
|
|
188
|
+
on hashing their requirements.
|
|
189
|
+
"""
|
|
190
|
+
# Determine base path
|
|
191
|
+
if self.venv_path.is_absolute():
|
|
192
|
+
return self.venv_path
|
|
193
|
+
if self.config_file is None: # pragma: no cover
|
|
194
|
+
# Defensive: shouldn't happen in normal CLI use (only in direct model_validate)
|
|
195
|
+
return Path.cwd() / self.venv_path
|
|
196
|
+
# Relative path - resolve from config file location
|
|
197
|
+
return self.config_file.parent / self.venv_path
|
|
198
|
+
|
|
199
|
+
def select_environment(self, env_name: str | None) -> UvToolboxEnvironment:
|
|
200
|
+
"""Select an environment by name.
|
|
201
|
+
|
|
202
|
+
If env_name is none
|
|
203
|
+
"""
|
|
204
|
+
env_name = env_name or self.default_environment
|
|
205
|
+
|
|
206
|
+
if env_name is not None:
|
|
207
|
+
for env in self.environments:
|
|
208
|
+
if env.name == env_name:
|
|
209
|
+
return env
|
|
210
|
+
available = [env.name for env in self.environments]
|
|
211
|
+
raise EnvironmentNotFoundError(env_name, available)
|
|
212
|
+
|
|
213
|
+
if len(self.environments) == 1:
|
|
214
|
+
return self.environments[0]
|
|
215
|
+
|
|
216
|
+
available = [env.name for env in self.environments]
|
|
217
|
+
raise MultipleEnvironmentsError(available)
|
|
218
|
+
|
|
219
|
+
@model_validator(mode='after')
|
|
220
|
+
def ensure_unique_env_names(
|
|
221
|
+
self,
|
|
222
|
+
) -> UvToolboxSettings:
|
|
223
|
+
"""Validate that environment names are unique."""
|
|
224
|
+
all_env_names = [env.name for env in self.environments]
|
|
225
|
+
duplicate_names = {name for name in all_env_names if all_env_names.count(name) > 1}
|
|
226
|
+
if duplicate_names:
|
|
227
|
+
msg = f'Duplicate environment names found: {", ".join(sorted(duplicate_names))}'
|
|
228
|
+
raise ValueError(msg)
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
@model_validator(mode='after')
|
|
232
|
+
def ensure_valid_default_environment(
|
|
233
|
+
self,
|
|
234
|
+
) -> UvToolboxSettings:
|
|
235
|
+
"""Validate that the default environment name exists."""
|
|
236
|
+
if self.default_environment is not None:
|
|
237
|
+
env_names = {env.name for env in self.environments}
|
|
238
|
+
if self.default_environment not in env_names:
|
|
239
|
+
msg = f'Default environment {self.default_environment!r} not found in environments.'
|
|
240
|
+
raise ValueError(msg)
|
|
241
|
+
return self
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def from_context(
|
|
245
|
+
cls,
|
|
246
|
+
ctx: typer.Context,
|
|
247
|
+
**overrides: object,
|
|
248
|
+
) -> UvToolboxSettings:
|
|
249
|
+
"""Create settings from a Typer context and optional overrides.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
ctx: Typer context containing CLI args.
|
|
253
|
+
**overrides: Additional settings to override.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
UvToolboxSettings instance.
|
|
257
|
+
"""
|
|
258
|
+
cli_args = {
|
|
259
|
+
**(ctx.obj or {}),
|
|
260
|
+
**_filter_nulls(overrides),
|
|
261
|
+
}
|
|
262
|
+
_verify_config_file(cli_args)
|
|
263
|
+
with _suppress_unused_pyproject_warning():
|
|
264
|
+
return UvToolboxSettings.model_validate(cli_args)
|
|
265
|
+
|
|
266
|
+
@classmethod
|
|
267
|
+
def settings_customise_sources(
|
|
268
|
+
cls,
|
|
269
|
+
settings_cls: type[BaseSettings],
|
|
270
|
+
init_settings: PydanticBaseSettingsSource,
|
|
271
|
+
env_settings: PydanticBaseSettingsSource,
|
|
272
|
+
dotenv_settings: PydanticBaseSettingsSource, # noqa: ARG003
|
|
273
|
+
file_secret_settings: PydanticBaseSettingsSource, # noqa: ARG003
|
|
274
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
275
|
+
"""Customize settings sources for UV Toolbox."""
|
|
276
|
+
init_kwargs = typing.cast(
|
|
277
|
+
'InitSettingsSource',
|
|
278
|
+
init_settings,
|
|
279
|
+
).init_kwargs
|
|
280
|
+
config_file = init_kwargs.get('config_file')
|
|
281
|
+
|
|
282
|
+
if config_file is None:
|
|
283
|
+
config_file = os.environ.get('UV_TOOLBOX_CONFIG_FILE')
|
|
284
|
+
|
|
285
|
+
if config_file:
|
|
286
|
+
config_source = _config_file_source(settings_cls, Path(config_file))
|
|
287
|
+
return (
|
|
288
|
+
init_settings,
|
|
289
|
+
env_settings,
|
|
290
|
+
config_source,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
uv_toolbox_yaml = YamlConfigSettingsSource(
|
|
294
|
+
settings_cls,
|
|
295
|
+
yaml_file='uv-toolbox.yaml',
|
|
296
|
+
)
|
|
297
|
+
uv_toolbox_json = JsonConfigSettingsSource(
|
|
298
|
+
settings_cls,
|
|
299
|
+
json_file='uv-toolbox.json',
|
|
300
|
+
)
|
|
301
|
+
uv_toolbox_toml = TomlConfigSettingsSource(
|
|
302
|
+
settings_cls,
|
|
303
|
+
toml_file='uv-toolbox.toml',
|
|
304
|
+
)
|
|
305
|
+
pyproject_toml = PyprojectTomlConfigSettingsSource(settings_cls)
|
|
306
|
+
return (
|
|
307
|
+
init_settings,
|
|
308
|
+
env_settings,
|
|
309
|
+
uv_toolbox_yaml,
|
|
310
|
+
uv_toolbox_json,
|
|
311
|
+
uv_toolbox_toml,
|
|
312
|
+
pyproject_toml,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _config_file_source(
|
|
317
|
+
settings_cls: type[BaseSettings],
|
|
318
|
+
config_file: Path,
|
|
319
|
+
) -> PydanticBaseSettingsSource:
|
|
320
|
+
if config_file.name == 'pyproject.toml':
|
|
321
|
+
return PyprojectTomlConfigSettingsSource(
|
|
322
|
+
settings_cls,
|
|
323
|
+
toml_file=config_file,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
suffix = config_file.suffix.lower()
|
|
327
|
+
if suffix in {'.yaml', '.yml'}:
|
|
328
|
+
return YamlConfigSettingsSource(settings_cls, yaml_file=config_file)
|
|
329
|
+
if suffix == '.json':
|
|
330
|
+
return JsonConfigSettingsSource(settings_cls, json_file=config_file)
|
|
331
|
+
if suffix == '.toml':
|
|
332
|
+
return TomlConfigSettingsSource(settings_cls, toml_file=config_file)
|
|
333
|
+
msg = f'Unsupported config file extension: {config_file}'
|
|
334
|
+
raise ValueError(msg)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _pyproject_has_uv_toolbox_config(path: Path) -> bool:
|
|
338
|
+
if not path.is_file():
|
|
339
|
+
return False
|
|
340
|
+
try:
|
|
341
|
+
data = tomllib.loads(path.read_text())
|
|
342
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
343
|
+
return False
|
|
344
|
+
tool_section = data.get('tool')
|
|
345
|
+
if not isinstance(tool_section, dict):
|
|
346
|
+
return False
|
|
347
|
+
uv_section = tool_section.get('uv-toolbox')
|
|
348
|
+
return isinstance(uv_section, dict)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _check_directory_for_config(directory: Path) -> Path | None:
|
|
352
|
+
"""Check a directory for UV Toolbox config files.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
directory: Directory to check
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Path to config file if found, None otherwise
|
|
359
|
+
"""
|
|
360
|
+
config_names = [
|
|
361
|
+
'uv-toolbox.yaml',
|
|
362
|
+
'uv-toolbox.json',
|
|
363
|
+
'uv-toolbox.toml',
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
# Check for dedicated config files
|
|
367
|
+
for name in config_names:
|
|
368
|
+
config_path = directory / name
|
|
369
|
+
if config_path.exists():
|
|
370
|
+
return config_path
|
|
371
|
+
|
|
372
|
+
# Check pyproject.toml with [tool.uv-toolbox] section
|
|
373
|
+
pyproject = directory / 'pyproject.toml'
|
|
374
|
+
if pyproject.exists() and _pyproject_has_uv_toolbox_config(pyproject):
|
|
375
|
+
return pyproject
|
|
376
|
+
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _find_config_file(start_path: Path | None = None) -> Path | None:
|
|
381
|
+
"""Find UV Toolbox config file by walking up the directory tree.
|
|
382
|
+
|
|
383
|
+
Searches for config files in this order:
|
|
384
|
+
1. uv-toolbox.yaml
|
|
385
|
+
2. uv-toolbox.json
|
|
386
|
+
3. uv-toolbox.toml
|
|
387
|
+
4. pyproject.toml (with [tool.uv-toolbox] section)
|
|
388
|
+
|
|
389
|
+
Stops at:
|
|
390
|
+
- First config file found
|
|
391
|
+
- .git directory (project boundary)
|
|
392
|
+
- Filesystem root
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
start_path: Directory to start searching from (defaults to cwd)
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
Path to config file, or None if not found
|
|
399
|
+
"""
|
|
400
|
+
current = start_path or Path.cwd()
|
|
401
|
+
|
|
402
|
+
for parent in [current, *current.parents]:
|
|
403
|
+
# Check this directory for config files
|
|
404
|
+
config_file = _check_directory_for_config(parent)
|
|
405
|
+
if config_file:
|
|
406
|
+
return config_file
|
|
407
|
+
|
|
408
|
+
# Stop at git root as a boundary
|
|
409
|
+
if (parent / '.git').exists():
|
|
410
|
+
break
|
|
411
|
+
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _verify_config_file(cli_args: dict[str, object]) -> None:
|
|
416
|
+
config_file = cli_args.get('config_file') or os.getenv(
|
|
417
|
+
'UV_TOOLBOX_CONFIG_FILE',
|
|
418
|
+
)
|
|
419
|
+
if config_file:
|
|
420
|
+
config_path = Path(str(config_file))
|
|
421
|
+
if not config_path.exists():
|
|
422
|
+
raise ConfigFileNotFoundError(config_path)
|
|
423
|
+
# Store the config file path for later use
|
|
424
|
+
cli_args['config_file'] = config_path
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
# Search up the directory tree for a config file
|
|
428
|
+
found_config = _find_config_file()
|
|
429
|
+
if found_config:
|
|
430
|
+
# Store the found config file path
|
|
431
|
+
cli_args['config_file'] = found_config
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
# No config file found - show what we searched for
|
|
435
|
+
current = Path.cwd()
|
|
436
|
+
searched_dirs = [current, *current.parents]
|
|
437
|
+
# Stop at git root if found
|
|
438
|
+
for i, directory in enumerate(searched_dirs):
|
|
439
|
+
if (directory / '.git').exists():
|
|
440
|
+
searched_dirs = searched_dirs[: i + 1]
|
|
441
|
+
break
|
|
442
|
+
|
|
443
|
+
# Show example paths from the current directory
|
|
444
|
+
config_candidates = [
|
|
445
|
+
current / 'uv-toolbox.yaml',
|
|
446
|
+
current / 'uv-toolbox.json',
|
|
447
|
+
current / 'uv-toolbox.toml',
|
|
448
|
+
current / 'pyproject.toml',
|
|
449
|
+
]
|
|
450
|
+
raise MissingConfigFileError(config_candidates)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@contextmanager
|
|
454
|
+
def _suppress_unused_pyproject_warning() -> Iterator[None]:
|
|
455
|
+
with warnings.catch_warnings():
|
|
456
|
+
warnings.filterwarnings(
|
|
457
|
+
'ignore',
|
|
458
|
+
message=(
|
|
459
|
+
r'Config key `pyproject_toml_table_header` is set in model_config '
|
|
460
|
+
r'but will be ignored because no PyprojectTomlConfigSettingsSource '
|
|
461
|
+
r'source is configured\..*'
|
|
462
|
+
),
|
|
463
|
+
category=UserWarning,
|
|
464
|
+
)
|
|
465
|
+
yield
|
uv_toolbox/shims.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Shim generation for UV Toolbox.
|
|
2
|
+
|
|
3
|
+
Creates wrapper scripts for explicitly listed executables in virtual environments.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
import typing
|
|
11
|
+
|
|
12
|
+
from uv_toolbox.utils import _venv_bin_path
|
|
13
|
+
|
|
14
|
+
if typing.TYPE_CHECKING:
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from uv_toolbox.settings import UvToolboxEnvironment, UvToolboxSettings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _create_unix_shim(
|
|
21
|
+
shim_path: Path,
|
|
22
|
+
target_path: Path,
|
|
23
|
+
venv_path: Path,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Create a Unix shell script shim.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
shim_path: Path where the shim script should be created.
|
|
29
|
+
target_path: Path to the actual executable.
|
|
30
|
+
venv_path: Path to the virtual environment.
|
|
31
|
+
"""
|
|
32
|
+
python_exe = venv_path / 'bin' / 'python'
|
|
33
|
+
script = f"""#!/usr/bin/env bash
|
|
34
|
+
VIRTUAL_ENV="{venv_path}"
|
|
35
|
+
exec uv run --no-project --python "{python_exe}" -- "{target_path}" "$@"
|
|
36
|
+
"""
|
|
37
|
+
shim_path.write_text(script)
|
|
38
|
+
# Make executable
|
|
39
|
+
shim_path.chmod(
|
|
40
|
+
shim_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _create_windows_shim(
|
|
45
|
+
shim_path: Path,
|
|
46
|
+
target_path: Path,
|
|
47
|
+
venv_path: Path,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Create a Windows batch script shim.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
shim_path: Path where the shim script should be created (without extension).
|
|
53
|
+
target_path: Path to the actual executable.
|
|
54
|
+
venv_path: Path to the virtual environment.
|
|
55
|
+
"""
|
|
56
|
+
# Create .bat file
|
|
57
|
+
bat_path = shim_path.with_suffix('.bat')
|
|
58
|
+
python_exe = venv_path / 'Scripts' / 'python.exe'
|
|
59
|
+
script = f"""@echo off
|
|
60
|
+
set VIRTUAL_ENV={venv_path}
|
|
61
|
+
uv run --no-project --python "{python_exe}" -- "{target_path}" %*
|
|
62
|
+
"""
|
|
63
|
+
bat_path.write_text(script)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _find_windows_executable(bin_path: Path, exe_name: str) -> Path | None:
|
|
67
|
+
"""Find an executable in a Windows venv Scripts directory.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
bin_path: Path to the venv Scripts directory.
|
|
71
|
+
exe_name: Name of the executable to find.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Path to the executable if found, None otherwise.
|
|
75
|
+
"""
|
|
76
|
+
for ext in ['.exe', '.bat', '.cmd']:
|
|
77
|
+
exe_path = bin_path / f'{exe_name}{ext}'
|
|
78
|
+
if exe_path.exists():
|
|
79
|
+
return exe_path
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _find_unix_executable(bin_path: Path, exe_name: str) -> Path | None:
|
|
84
|
+
"""Find an executable in a Unix venv bin directory.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
bin_path: Path to the venv bin directory.
|
|
88
|
+
exe_name: Name of the executable to find.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Path to the executable if found, None otherwise.
|
|
92
|
+
"""
|
|
93
|
+
exe_path = bin_path / exe_name
|
|
94
|
+
if exe_path.exists() and os.access(exe_path, os.X_OK):
|
|
95
|
+
return exe_path
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _find_executable(bin_path: Path, exe_name: str) -> Path | None:
|
|
100
|
+
"""Find an executable in a venv bin directory.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
bin_path: Path to the venv bin directory.
|
|
104
|
+
exe_name: Name of the executable to find.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Path to the executable if found, None otherwise.
|
|
108
|
+
"""
|
|
109
|
+
if os.name == 'nt':
|
|
110
|
+
return _find_windows_executable(bin_path, exe_name)
|
|
111
|
+
return _find_unix_executable(bin_path, exe_name)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _create_shim_for_executable(
|
|
115
|
+
exe_name: str,
|
|
116
|
+
venv_path: Path,
|
|
117
|
+
bin_path: Path,
|
|
118
|
+
shim_dir: Path,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Create a shim for a single executable.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
exe_name: Name of the executable.
|
|
124
|
+
venv_path: Path to the virtual environment.
|
|
125
|
+
bin_path: Path to the venv bin directory.
|
|
126
|
+
shim_dir: Directory where the shim should be created.
|
|
127
|
+
"""
|
|
128
|
+
# Find the executable in the venv
|
|
129
|
+
target_path = _find_executable(bin_path, exe_name)
|
|
130
|
+
if target_path is None:
|
|
131
|
+
# Executable not found - skip silently
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Create shim path
|
|
135
|
+
shim_path = shim_dir / exe_name
|
|
136
|
+
|
|
137
|
+
# Create platform-specific shim
|
|
138
|
+
if os.name == 'nt':
|
|
139
|
+
_create_windows_shim(shim_path, target_path, venv_path)
|
|
140
|
+
else:
|
|
141
|
+
_create_unix_shim(shim_path, target_path, venv_path)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _create_shims_for_environment(
|
|
145
|
+
env: UvToolboxEnvironment,
|
|
146
|
+
settings: UvToolboxSettings,
|
|
147
|
+
) -> Path | None:
|
|
148
|
+
"""Create shim directory for a single environment.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
env: The environment to create shims for.
|
|
152
|
+
settings: The UV toolbox settings.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Path to the shim directory if created, None otherwise.
|
|
156
|
+
"""
|
|
157
|
+
venv_path = env.venv_path(settings=settings)
|
|
158
|
+
|
|
159
|
+
# Skip if venv doesn't exist yet
|
|
160
|
+
if not venv_path.exists():
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
# Skip if no executables listed
|
|
164
|
+
if not env.executables:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
# Shim directory is inside the venv
|
|
168
|
+
shim_dir = venv_path / 'shims'
|
|
169
|
+
shim_dir.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
|
|
171
|
+
# Clear existing shims in this venv
|
|
172
|
+
for shim_file in shim_dir.iterdir():
|
|
173
|
+
if shim_file.is_file():
|
|
174
|
+
shim_file.unlink()
|
|
175
|
+
|
|
176
|
+
# Create shims for this environment's executables
|
|
177
|
+
bin_path = _venv_bin_path(venv_path)
|
|
178
|
+
for exe_name in env.executables:
|
|
179
|
+
_create_shim_for_executable(exe_name, venv_path, bin_path, shim_dir)
|
|
180
|
+
|
|
181
|
+
return shim_dir
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def create_shims(settings: UvToolboxSettings) -> list[Path]:
|
|
185
|
+
"""Create per-venv shim scripts for explicitly listed executables.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
settings: The UV toolbox settings.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List of shim directories in config order (for PATH precedence).
|
|
192
|
+
"""
|
|
193
|
+
shim_dirs: list[Path] = []
|
|
194
|
+
|
|
195
|
+
for env in settings.environments:
|
|
196
|
+
shim_dir = _create_shims_for_environment(env, settings)
|
|
197
|
+
if shim_dir is not None:
|
|
198
|
+
shim_dirs.append(shim_dir)
|
|
199
|
+
|
|
200
|
+
return shim_dirs
|
uv_toolbox/utils.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _filter_nulls(d: dict[str, Any]) -> dict[str, Any]:
|
|
7
|
+
"""Return a copy of the dictionary with all None values removed.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
d: The dictionary to filter.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
A new dictionary with all None values removed.
|
|
14
|
+
"""
|
|
15
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _venv_bin_path(venv_path: Path) -> Path:
|
|
19
|
+
"""Return the path to the 'bin' (or 'Scripts' on Windows) directory of the virtualenv.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
venv_path: The path to the virtual environment.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The path to the 'bin' or 'Scripts' directory within the virtual
|
|
26
|
+
environment.
|
|
27
|
+
"""
|
|
28
|
+
return venv_path / ('Scripts' if os.name == 'nt' else 'bin')
|
uv_toolbox/uv_helpers.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from uv_toolbox.process import run_checked
|
|
6
|
+
from uv_toolbox.settings import UvToolboxEnvironment, UvToolboxSettings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_virtualenv(
|
|
10
|
+
env: UvToolboxEnvironment,
|
|
11
|
+
settings: UvToolboxSettings,
|
|
12
|
+
*,
|
|
13
|
+
clear: bool = False,
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Create a Python virtual environment at the specified path.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
env: The UV toolbox environment to create the virtualenv for.
|
|
19
|
+
settings: The UV toolbox settings.
|
|
20
|
+
clear: If True, clear the existing venv if it already exists.
|
|
21
|
+
"""
|
|
22
|
+
args = ['uv', 'venv', str(env.venv_path(settings=settings))]
|
|
23
|
+
if clear:
|
|
24
|
+
args.append('--clear')
|
|
25
|
+
|
|
26
|
+
run_checked(
|
|
27
|
+
args=args,
|
|
28
|
+
extra_env=env.process_env(settings=settings),
|
|
29
|
+
capture_stdout=False,
|
|
30
|
+
capture_stderr=False,
|
|
31
|
+
show_command=settings.show_commands,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def install_requirements(
|
|
36
|
+
env: UvToolboxEnvironment,
|
|
37
|
+
settings: UvToolboxSettings,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Install the requirements for the given environment into its virtualenv.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
env: The UV toolbox environment to install requirements for.
|
|
43
|
+
settings: The UV toolbox settings.
|
|
44
|
+
"""
|
|
45
|
+
temp_dir: Path | None = None
|
|
46
|
+
|
|
47
|
+
if env.requirements_file is not None:
|
|
48
|
+
reqs_arg = ['-r', str(env.requirements_file)]
|
|
49
|
+
elif env.requirements is not None:
|
|
50
|
+
temp_dir = Path(tempfile.mkdtemp())
|
|
51
|
+
temp_req_file = temp_dir / f'requirements_{env.name}.txt'
|
|
52
|
+
temp_req_file.write_text(env.requirements)
|
|
53
|
+
reqs_arg = ['-r', str(temp_req_file)]
|
|
54
|
+
|
|
55
|
+
if reqs_arg:
|
|
56
|
+
run_checked(
|
|
57
|
+
args=[
|
|
58
|
+
'uv',
|
|
59
|
+
'pip',
|
|
60
|
+
'install',
|
|
61
|
+
*reqs_arg,
|
|
62
|
+
'--exact',
|
|
63
|
+
],
|
|
64
|
+
extra_env=env.process_env(settings=settings),
|
|
65
|
+
capture_stdout=False,
|
|
66
|
+
capture_stderr=False,
|
|
67
|
+
show_command=settings.show_commands,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if temp_dir is not None:
|
|
71
|
+
shutil.rmtree(temp_dir)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def initialize_virtualenv(
|
|
75
|
+
env: UvToolboxEnvironment,
|
|
76
|
+
settings: UvToolboxSettings,
|
|
77
|
+
*,
|
|
78
|
+
clear: bool = False,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Create and set up the virtual environment for the given environment.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
env: The UV toolbox environment to initialize.
|
|
84
|
+
settings: The UV toolbox settings.
|
|
85
|
+
clear: If True, clear and recreate the virtual environment.
|
|
86
|
+
"""
|
|
87
|
+
venv_path = env.venv_path(settings=settings)
|
|
88
|
+
|
|
89
|
+
# Only create venv if it doesn't exist or clear is True
|
|
90
|
+
if not venv_path.exists() or clear:
|
|
91
|
+
create_virtualenv(env=env, settings=settings, clear=True)
|
|
92
|
+
|
|
93
|
+
install_requirements(env=env, settings=settings)
|
|
@@ -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,12 @@
|
|
|
1
|
+
uv_toolbox/__init__.py,sha256=JIdAYwocL_YJlow7ikHY1lbKDUr7tC1b4BHwtL4R60s,82
|
|
2
|
+
uv_toolbox/cli.py,sha256=lZX-1d4zSlK8bwdMub8vU4O8MhdC0NeqK4xthK9nPpc,4738
|
|
3
|
+
uv_toolbox/errors.py,sha256=SKB3sqOvskBQPhXVXCuGk2WppbaOIMroCBl7_oSZlKI,3466
|
|
4
|
+
uv_toolbox/process.py,sha256=hsI7rRTcZtd8-EHbNDYBPTU6vwBlkGP70mvwnptYBZA,2308
|
|
5
|
+
uv_toolbox/settings.py,sha256=ojVjKheINGFztSeCGWzVKMt5wI1Oz5gEUpUvVlM8lTc,15001
|
|
6
|
+
uv_toolbox/shims.py,sha256=qrYtbquTcJkpTS3JyWJnK6er553UZ7GK2psr_uVgfWc,5517
|
|
7
|
+
uv_toolbox/utils.py,sha256=fdgcSl0jwQmfGBiZaACamcqV3Lkvgt_LllHIP-UEVz0,756
|
|
8
|
+
uv_toolbox/uv_helpers.py,sha256=eCYiN7MGSmRR1K6SKBtCKHAXV15SLuqQYFc1xeMhTrU,2685
|
|
9
|
+
uv_toolbox-0.1.0.dist-info/WHEEL,sha256=e_m4S054HL0hyR3CpOk-b7Q7fDX6BuFkgL5OjAExXas,80
|
|
10
|
+
uv_toolbox-0.1.0.dist-info/entry_points.txt,sha256=WftDfbKQ8qq2Y92Q7GT3gcZBr9IWcGdSMH-A0-YZE7c,79
|
|
11
|
+
uv_toolbox-0.1.0.dist-info/METADATA,sha256=ktmoutjVBKUecMe8ez9DelrDjwlOaK_AqpPL20zXloM,4240
|
|
12
|
+
uv_toolbox-0.1.0.dist-info/RECORD,,
|