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.
@@ -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,98 @@
1
+ # uv-toolbox
2
+
3
+ [![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)
4
+ [![PyPI](https://img.shields.io/pypi/v/uv-toolbox.svg)](https://pypi.org/project/uv-toolbox/)
5
+ [![Python Version](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![codecov](https://codecov.io/gh/hotdog-werx/uv-toolbox/branch/master/graph/badge.svg)](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,3 @@
1
+ import importlib.metadata
2
+
3
+ __version__ = importlib.metadata.version('uv_toolbox')
@@ -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 ''