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 ADDED
@@ -0,0 +1,3 @@
1
+ import importlib.metadata
2
+
3
+ __version__ = importlib.metadata.version('uv_toolbox')
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')
@@ -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
+ [![CI](https://github.com/hotdog-werx/uv-toolbox/actions/workflows/ci-checks.yaml/badge.svg)](https://github.com/hotdog-werx/uv-toolbox/actions/workflows/ci.yml)
31
+ [![PyPI](https://img.shields.io/pypi/v/uv-toolbox.svg)](https://pypi.org/project/uv-toolbox/)
32
+ [![Python Version](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
34
+ [![codecov](https://codecov.io/gh/hotdog-werx/uv-toolbox/branch/master/graph/badge.svg)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.27
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ uv-toolbox = uv_toolbox.cli:main
3
+ uvtb = uv_toolbox.cli:main
4
+