uvg 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.
uvg-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.3
2
+ Name: uvg
3
+ Version: 0.1.0
4
+ Summary: A global virtual environment manager built on top of uv
5
+ Author: Noai-oss
6
+ Author-email: Noai-oss <jiuwoxiao@outlook.com>
7
+ Requires-Dist: typer>=0.24.1
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
11
+ # uvg
12
+
13
+ A global Python virtual environment manager built on `uv`. Environments default to `~/.uvg/venvs`.
14
+
15
+ **`uv` for projects, `uvg` for environments.**
16
+
17
+ Set `UVG_HOME` to move the uvg home directory.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ uv tool install .
23
+ # or
24
+ uv tool install git+https://github.com/Noai-oss/uvg
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```bash
30
+ # Initialize shell integration (pick one)
31
+ uvg init bash --profile ~/.bashrc
32
+ uvg init zsh --profile ~/.zshrc
33
+ uvg init pwsh --profile $PROFILE
34
+
35
+ # Create and activate an environment
36
+ uvg create myenv --python 3.12
37
+ uvg activate myenv
38
+
39
+ # Install packages
40
+ uv pip install ruff black
41
+ ```
42
+
43
+ ## Commands
44
+
45
+ | Command | Description | Example |
46
+ | --- | --- | --- |
47
+ | `uvg create <name>` | Create an environment | `uvg create data -p 3.11` |
48
+ | `uvg activate <name>` | Activate an environment | `uvg activate data` |
49
+ | `uvg remove <name>` | Remove an environment | `uvg remove data -y` |
50
+ | `uvg init <shell>` | Initialize shell integration for `bash`, `zsh`, or `pwsh` | `uvg init zsh -p ~/.zshrc` |
51
+ | `uvg env list` | List all environments | `uvg env list` |
52
+ | `uvg env current` | Show current environment | `uvg env current` |
53
+ | `uvg env dir` | Print the managed environments directory | `uvg env dir` |
uvg-0.1.0/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # uvg
2
+
3
+ A global Python virtual environment manager built on `uv`. Environments default to `~/.uvg/venvs`.
4
+
5
+ **`uv` for projects, `uvg` for environments.**
6
+
7
+ Set `UVG_HOME` to move the uvg home directory.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ uv tool install .
13
+ # or
14
+ uv tool install git+https://github.com/Noai-oss/uvg
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ # Initialize shell integration (pick one)
21
+ uvg init bash --profile ~/.bashrc
22
+ uvg init zsh --profile ~/.zshrc
23
+ uvg init pwsh --profile $PROFILE
24
+
25
+ # Create and activate an environment
26
+ uvg create myenv --python 3.12
27
+ uvg activate myenv
28
+
29
+ # Install packages
30
+ uv pip install ruff black
31
+ ```
32
+
33
+ ## Commands
34
+
35
+ | Command | Description | Example |
36
+ | --- | --- | --- |
37
+ | `uvg create <name>` | Create an environment | `uvg create data -p 3.11` |
38
+ | `uvg activate <name>` | Activate an environment | `uvg activate data` |
39
+ | `uvg remove <name>` | Remove an environment | `uvg remove data -y` |
40
+ | `uvg init <shell>` | Initialize shell integration for `bash`, `zsh`, or `pwsh` | `uvg init zsh -p ~/.zshrc` |
41
+ | `uvg env list` | List all environments | `uvg env list` |
42
+ | `uvg env current` | Show current environment | `uvg env current` |
43
+ | `uvg env dir` | Print the managed environments directory | `uvg env dir` |
@@ -0,0 +1,96 @@
1
+ [project]
2
+ name = "uvg"
3
+ version = "0.1.0"
4
+ description = "A global virtual environment manager built on top of uv"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Noai-oss", email = "jiuwoxiao@outlook.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "typer>=0.24.1",
12
+ ]
13
+
14
+ [project.scripts]
15
+ uvg = "uvg.__main__:main"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.11.7,<0.12.0"]
19
+ build-backend = "uv_build"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pytest>=9.0.3",
24
+ "ruff>=0.15.11",
25
+ "ty>=0.0.31",
26
+ "typos>=1.45.1",
27
+ ]
28
+
29
+ [tool.git-cliff.changelog]
30
+ header = """
31
+ # Changelog
32
+
33
+ All notable changes to this project will be documented in this file.
34
+
35
+ """
36
+ body = """
37
+ {% if version %}\
38
+ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
39
+ {% else %}\
40
+ ## [Unreleased]
41
+ {% endif %}\
42
+ {% for group, commits in commits | group_by(attribute="group") %}
43
+ ### {{ group | striptags | trim | upper_first }}
44
+ {% for commit in commits %}
45
+ - {% if commit.scope %}*({{ commit.scope }})* {% endif %}{% if commit.breaking %}[BREAKING] {% endif %}{{ commit.message | upper_first }}\
46
+ {% endfor %}
47
+ {% endfor %}
48
+ """
49
+ footer = """
50
+
51
+ <!-- generated by git-cliff -->
52
+ """
53
+ trim = true
54
+ postprocessors = [
55
+ { pattern = "<REPO>", replace = "https://github.com/Noai-oss/uvg" },
56
+ ]
57
+
58
+ [tool.git-cliff.git]
59
+ conventional_commits = true
60
+ filter_unconventional = true
61
+ require_conventional = false
62
+ split_commits = false
63
+ commit_preprocessors = [
64
+ { pattern = "^(\\w+(?:\\([^)]*\\))?!?:)\\s*[^\\x00-\\x7F]+\\s*", replace = "${1} " },
65
+ { pattern = "\\((\\w+\\s)?#([0-9]+)\\)", replace = "([#${2}](<REPO>/issues/${2}))" },
66
+ ]
67
+ protect_breaking_commits = false
68
+ commit_parsers = [
69
+ { message = "^feat", group = "<!-- 0 -->Added" },
70
+ { message = "^fix", group = "<!-- 1 -->Fixed" },
71
+ { message = "^refactor", group = "<!-- 2 -->Changed" },
72
+ { message = "^perf", group = "<!-- 3 -->Performance" },
73
+ { message = "^doc", group = "<!-- 4 -->Documentation" },
74
+ { message = "^test", group = "<!-- 5 -->Tests" },
75
+ { message = "^ci", group = "<!-- 6 -->CI" },
76
+ { message = "^chore\\(bot\\)", skip = true },
77
+ { message = "^chore\\(deps.*\\)", skip = true },
78
+ { message = "^chore\\(release\\):", skip = true },
79
+ { message = "^chore", group = "<!-- 7 -->Maintenance" },
80
+ { message = "^revert", group = "<!-- 8 -->Reverts" },
81
+ { message = ".*", group = "<!-- 9 -->Other" },
82
+ ]
83
+ filter_commits = false
84
+ fail_on_unmatched_commit = false
85
+ tag_pattern = "v[0-9].*"
86
+ link_parsers = []
87
+ use_branch_tags = true
88
+ topo_order = false
89
+ topo_order_commits = true
90
+ sort_commits = "oldest"
91
+
92
+ [tool.git-cliff.bump]
93
+ initial_tag = "v0.1.0"
94
+ features_always_bump_minor = true
95
+ breaking_always_bump_major = false
96
+ no_increment_regex = "^chore|^ci|^docs?|^test"
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+
6
+ try:
7
+ __version__ = version("uvg")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0"
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Sequence
5
+
6
+ import click
7
+ import typer
8
+
9
+ from uvg.cli import app
10
+ from uvg.core.errors import UvgError
11
+
12
+
13
+ def main(argv: Sequence[str] | None = None) -> int:
14
+ """Main entry point for the uvg CLI."""
15
+ try:
16
+ app(prog_name="uvg", args=argv, standalone_mode=False)
17
+ return 0
18
+ except typer.Exit as exc:
19
+ return exc.exit_code
20
+ except click.ClickException as exc:
21
+ exc.show(file=sys.stderr)
22
+ return exc.exit_code
23
+ except (KeyboardInterrupt, typer.Abort):
24
+ typer.echo("Interrupted.", err=True)
25
+ return 130
26
+ except UvgError as exc:
27
+ typer.echo(f"Error: {exc}", err=True)
28
+ return 1
29
+
30
+
31
+ if __name__ == "__main__":
32
+ sys.exit(main())
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from uvg.commands import activate, create, init, remove
6
+ from uvg.commands.env import app as env_app
7
+
8
+
9
+ app = typer.Typer(
10
+ name="uvg",
11
+ help="uvg: a global virtual environment manager built on top of uv",
12
+ add_completion=False,
13
+ no_args_is_help=True,
14
+ )
15
+
16
+ app.add_typer(create.app)
17
+ app.add_typer(remove.app)
18
+ app.add_typer(init.app)
19
+ app.add_typer(activate.app)
20
+ app.add_typer(env_app, name="env", help="Commands for managing virtual environments")
@@ -0,0 +1 @@
1
+ """Command modules for the uvg CLI."""
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from uvg.core.environment import resolve_path
9
+ from uvg.core.shell import (
10
+ ShellName,
11
+ get_default_shell_type_for_current_platform,
12
+ render_activation_command,
13
+ )
14
+
15
+
16
+ app = typer.Typer()
17
+
18
+
19
+ @app.command("activate")
20
+ def activate_environment_command(
21
+ environment_name: Annotated[str, typer.Argument(help="Environment name")],
22
+ shell_name: Annotated[
23
+ ShellName | None,
24
+ typer.Option(
25
+ "--shell",
26
+ help="Shell to generate activation code for",
27
+ ),
28
+ ] = None,
29
+ ) -> None:
30
+ """Generate activation command for an environment."""
31
+ managed_environment_path = resolve_path(environment_name)
32
+ resolved_shell_name = shell_name or get_default_shell_type_for_current_platform()
33
+ activation_command = render_activation_command(
34
+ managed_environment_path,
35
+ resolved_shell_name,
36
+ )
37
+ # Activation output is shell code consumed by eval/Invoke-Expression.
38
+ sys.stdout.buffer.write(f"{activation_command}\n".encode("utf-8"))
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from uvg.core.environment import create
8
+
9
+
10
+ app = typer.Typer()
11
+
12
+
13
+ @app.command("create")
14
+ def create_environment_command(
15
+ environment_name: Annotated[str, typer.Argument(help="Environment name")],
16
+ python_version: Annotated[
17
+ str | None,
18
+ typer.Option("--python", "-p", help="Python version to use, for example 3.12"),
19
+ ] = None,
20
+ ) -> None:
21
+ """Create a new managed environment."""
22
+ managed_environment_path = create(environment_name, python_version)
23
+ typer.echo(f"Created environment '{managed_environment_path.name}'")
24
+ typer.echo(f"Path: {managed_environment_path}")
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from uvg.commands.env import current, directory
6
+ from uvg.commands.env import list as list_command
7
+
8
+
9
+ app = typer.Typer(
10
+ name="env",
11
+ help="Commands for managing virtual environments",
12
+ add_completion=False,
13
+ no_args_is_help=True,
14
+ )
15
+
16
+ app.add_typer(list_command.app)
17
+ app.add_typer(directory.app)
18
+ app.add_typer(current.app)
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from uvg.core.environment import get_current_name
6
+
7
+
8
+ app = typer.Typer()
9
+
10
+
11
+ @app.command("current")
12
+ def show_current_environment_command() -> None:
13
+ """Show the currently active environment."""
14
+ active_environment_name = get_current_name()
15
+ typer.echo(active_environment_name)
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from uvg.core.environment import get_venvs_dir
6
+
7
+
8
+ app = typer.Typer()
9
+
10
+
11
+ @app.command("dir")
12
+ def show_environment_dir_command() -> None:
13
+ """Show environment dir."""
14
+ typer.echo(get_venvs_dir())
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from uvg.core.environment import build_path, list_names, read_python_version
6
+
7
+
8
+ app = typer.Typer()
9
+
10
+
11
+ @app.command("list")
12
+ def list_environments_command() -> None:
13
+ """List all managed environments."""
14
+ environment_names = list_names()
15
+ if not environment_names:
16
+ return
17
+
18
+ name_width = max(len(environment_name) for environment_name in environment_names)
19
+ for environment_name in environment_names:
20
+ environment_path = build_path(environment_name)
21
+ python_version = read_python_version(environment_path) or "unknown"
22
+ typer.echo(f"{environment_name:<{name_width}} {python_version}")
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from uvg.core.shell import (
10
+ IS_WINDOWS,
11
+ ShellName,
12
+ append_shell_integration_to_profile,
13
+ convert_windows_path_to_msys_path,
14
+ render_shell_integration_script,
15
+ )
16
+
17
+
18
+ app = typer.Typer()
19
+
20
+
21
+ @app.command("init")
22
+ def initialize_shell_integration_command(
23
+ shell_name: Annotated[ShellName, typer.Argument(help="Shell type")],
24
+ profile_path: Annotated[
25
+ Path | None,
26
+ typer.Option(
27
+ "--profile",
28
+ "-p",
29
+ help="Append the shell integration snippet to the given profile file",
30
+ ),
31
+ ] = None,
32
+ ) -> None:
33
+ """Initialize shell integration for uvg."""
34
+ if profile_path is None:
35
+ script_content = render_shell_integration_script(shell_name) + "\n"
36
+ # Shell integration output is shell code consumed by eval/Invoke-Expression.
37
+ sys.stdout.buffer.write(script_content.encode("utf-8"))
38
+ return
39
+
40
+ expanded_profile_path = profile_path.expanduser()
41
+ did_append_profile_snippet = append_shell_integration_to_profile(
42
+ shell_name,
43
+ expanded_profile_path,
44
+ )
45
+
46
+ if not did_append_profile_snippet:
47
+ typer.echo(f"uvg initialization is already present in {expanded_profile_path}")
48
+ return
49
+
50
+ expanded_profile_path_str = (
51
+ convert_windows_path_to_msys_path(expanded_profile_path)
52
+ if IS_WINDOWS
53
+ else str(expanded_profile_path)
54
+ )
55
+ typer.echo(
56
+ f"Successfully appended uvg initialization to {expanded_profile_path_str}"
57
+ )
58
+ typer.echo("Please restart your shell or source your profile to apply changes.")
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from uvg.core.environment import remove
8
+
9
+
10
+ app = typer.Typer()
11
+
12
+
13
+ @app.command("remove")
14
+ def remove_environment_command(
15
+ environment_name: Annotated[str, typer.Argument(help="Environment name")],
16
+ assume_yes: Annotated[
17
+ bool,
18
+ typer.Option("--yes", "-y", help="Remove without confirmation"),
19
+ ] = False,
20
+ ) -> None:
21
+ """Remove a managed environment."""
22
+ if not assume_yes:
23
+ should_remove_environment = typer.confirm(
24
+ f"Remove environment '{environment_name}'?",
25
+ default=False,
26
+ )
27
+ if not should_remove_environment:
28
+ typer.echo("Aborted.")
29
+ raise typer.Exit(code=0)
30
+
31
+ remove(environment_name)
32
+ typer.echo(f"Removed environment '{environment_name}'")
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from .errors import UvgError
11
+
12
+
13
+ # ============================================================================
14
+ # Layout
15
+ # ============================================================================
16
+
17
+ UVG_HOME_ENV_VAR = "UVG_HOME"
18
+ DEFAULT_UVG_HOME_DIR = Path.home() / ".uvg"
19
+ VENVS_DIR_NAME = "venvs"
20
+ NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
21
+
22
+
23
+ # ============================================================================
24
+ # Types
25
+ # ============================================================================
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class EnvironmentInfo:
30
+ name: str
31
+ path: Path
32
+ python_executable: Path
33
+ exists: bool
34
+
35
+
36
+ # ============================================================================
37
+ # Layout Functions
38
+ # ============================================================================
39
+
40
+
41
+ def ensure_layout() -> None:
42
+ """Create the managed environments directory structure."""
43
+ get_venvs_dir().mkdir(parents=True, exist_ok=True)
44
+
45
+
46
+ def build_path(environment_name: str) -> Path:
47
+ """Build the path for a managed environment."""
48
+ return get_venvs_dir() / environment_name
49
+
50
+
51
+ def get_uvg_home_dir() -> Path:
52
+ """Return the uvg home directory."""
53
+ configured_home_directory = os.environ.get(UVG_HOME_ENV_VAR)
54
+ if configured_home_directory:
55
+ return Path(configured_home_directory).expanduser()
56
+ return DEFAULT_UVG_HOME_DIR
57
+
58
+
59
+ def get_venvs_dir() -> Path:
60
+ """Return the managed environments directory."""
61
+ return get_uvg_home_dir() / VENVS_DIR_NAME
62
+
63
+
64
+ def is_valid_path(path: str | Path) -> bool:
65
+ """Check if a path is within the managed environments directory."""
66
+ try:
67
+ resolved_path = Path(path).resolve()
68
+ managed_root_directory = get_venvs_dir().resolve()
69
+ resolved_path.relative_to(managed_root_directory)
70
+ except (OSError, ValueError):
71
+ return False
72
+ return True
73
+
74
+
75
+ def extract_name_from_path(path: str | Path) -> str | None:
76
+ """Extract the environment name from a managed environment path."""
77
+ try:
78
+ resolved_path = Path(path).resolve()
79
+ managed_root_directory = get_venvs_dir().resolve()
80
+ relative_path = resolved_path.relative_to(managed_root_directory)
81
+ except (OSError, ValueError):
82
+ return None
83
+
84
+ return relative_path.parts[0] if relative_path.parts else None
85
+
86
+
87
+ # ============================================================================
88
+ # Validation
89
+ # ============================================================================
90
+
91
+
92
+ def validate_name(environment_name: str) -> str:
93
+ """Validate and normalize an environment name."""
94
+ normalized_environment_name = environment_name.strip()
95
+
96
+ if not normalized_environment_name:
97
+ raise UvgError("Environment name cannot be empty.")
98
+
99
+ if not NAME_PATTERN.fullmatch(normalized_environment_name):
100
+ raise UvgError(
101
+ "Environment name may only contain letters, numbers, dots, underscores, and hyphens."
102
+ )
103
+
104
+ return normalized_environment_name
105
+
106
+
107
+ # ============================================================================
108
+ # Runtime / Current Environment
109
+ # ============================================================================
110
+
111
+
112
+ def get_current_name(*, silent: bool = False) -> str | None:
113
+ """Get the name of the currently active managed environment."""
114
+ active_virtual_environment_path = os.environ.get("VIRTUAL_ENV")
115
+
116
+ if not active_virtual_environment_path:
117
+ if silent:
118
+ return None
119
+ raise UvgError("No active virtual environment.")
120
+
121
+ if not is_valid_path(active_virtual_environment_path):
122
+ if silent:
123
+ return None
124
+ raise UvgError(
125
+ "An active virtual environment was found, but it is not managed by uvg.\n"
126
+ f"Path: {active_virtual_environment_path}"
127
+ )
128
+
129
+ environment_name = extract_name_from_path(active_virtual_environment_path)
130
+ if not environment_name:
131
+ if silent:
132
+ return None
133
+ raise UvgError("Failed to determine current managed environment name.")
134
+
135
+ return environment_name
136
+
137
+
138
+ # ============================================================================
139
+ # Registry Operations
140
+ # ============================================================================
141
+
142
+
143
+ def list_names() -> list[str]:
144
+ """List all managed environment names."""
145
+ managed_environments_directory = get_venvs_dir()
146
+ if not managed_environments_directory.exists():
147
+ return []
148
+
149
+ environment_names = [
150
+ candidate_path.name
151
+ for candidate_path in managed_environments_directory.iterdir()
152
+ if candidate_path.is_dir()
153
+ ]
154
+ return sorted(environment_names)
155
+
156
+
157
+ def resolve_path(environment_name: str) -> Path:
158
+ """Resolve and validate an environment path."""
159
+ normalized_environment_name = validate_name(environment_name)
160
+ managed_environment_path = build_path(normalized_environment_name)
161
+
162
+ if not managed_environment_path.exists():
163
+ raise UvgError(f"Environment '{normalized_environment_name}' does not exist.")
164
+ if not managed_environment_path.is_dir():
165
+ raise UvgError(
166
+ f"Path for environment '{normalized_environment_name}' exists but is not a directory."
167
+ )
168
+
169
+ return managed_environment_path
170
+
171
+
172
+ def remove(environment_name: str) -> None:
173
+ """Remove a managed environment."""
174
+ normalized_environment_name = validate_name(environment_name)
175
+ managed_environment_path = resolve_path(normalized_environment_name)
176
+
177
+ if get_current_name(silent=True) == normalized_environment_name:
178
+ raise UvgError(
179
+ f"Environment '{normalized_environment_name}' is currently active. "
180
+ "Deactivate it before removing."
181
+ )
182
+
183
+ shutil.rmtree(managed_environment_path)
184
+
185
+
186
+ # ============================================================================
187
+ # UV Integration - Creation
188
+ # ============================================================================
189
+
190
+
191
+ def create(
192
+ environment_name: str,
193
+ python_version: str | None = None,
194
+ ) -> Path:
195
+ """Create a new managed environment using uv venv."""
196
+ ensure_layout()
197
+
198
+ normalized_environment_name = validate_name(environment_name)
199
+ managed_environment_path = build_path(normalized_environment_name)
200
+
201
+ if managed_environment_path.exists():
202
+ raise UvgError(f"Environment '{normalized_environment_name}' already exists.")
203
+
204
+ create_environment_command = ["uv", "venv", str(managed_environment_path), "--seed"]
205
+ if python_version:
206
+ create_environment_command.extend(["--python", python_version])
207
+
208
+ try:
209
+ completed_process = subprocess.run(
210
+ create_environment_command,
211
+ capture_output=True,
212
+ check=False,
213
+ text=True,
214
+ )
215
+ except FileNotFoundError as exc:
216
+ raise UvgError(
217
+ "The `uv` executable was not found. Install `uv` and ensure it is available on PATH."
218
+ ) from exc
219
+
220
+ if completed_process.returncode != 0:
221
+ standard_error_output = (completed_process.stderr or "").strip()
222
+ raise UvgError(
223
+ f"Failed to create environment '{normalized_environment_name}'."
224
+ + (f"\n{standard_error_output}" if standard_error_output else "")
225
+ )
226
+
227
+ return managed_environment_path
228
+
229
+
230
+ def read_python_version(environment_path: Path) -> str | None:
231
+ """Read the Python version used by a managed environment."""
232
+ pyvenv_cfg_path = environment_path / "pyvenv.cfg"
233
+ if not pyvenv_cfg_path.exists():
234
+ return None
235
+
236
+ for line in pyvenv_cfg_path.read_text(encoding="utf-8").splitlines():
237
+ if "=" not in line:
238
+ continue
239
+ key, value = line.split("=", 1)
240
+ if key.strip() == "version_info":
241
+ return value.strip()
242
+ return None
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class UvgError(Exception):
5
+ """Raised when a uvg command cannot complete successfully."""
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ import sys
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Literal, TypeAlias
8
+
9
+
10
+ class ShellName(str, Enum):
11
+ bash = "bash"
12
+ zsh = "zsh"
13
+ pwsh = "pwsh"
14
+
15
+ @property
16
+ def is_posix(self) -> bool:
17
+ return self in {ShellName.bash, ShellName.zsh}
18
+
19
+
20
+ PosixShellName: TypeAlias = Literal[ShellName.bash, ShellName.zsh]
21
+
22
+ IS_WINDOWS = sys.platform == "win32"
23
+
24
+
25
+ # ============================================================================
26
+ # Constants
27
+ # ============================================================================
28
+
29
+ PROFILE_SNIPPET_START_MARKER = "# >>> uvg initialize >>>"
30
+ PROFILE_SNIPPET_END_MARKER = "# <<< uvg initialize <<<"
31
+
32
+
33
+ # ============================================================================
34
+ # Shell Detection
35
+ # ============================================================================
36
+
37
+
38
+ def get_default_shell_type_for_current_platform() -> ShellName:
39
+ """Get the default shell type for the current operating system."""
40
+ return ShellName.pwsh if IS_WINDOWS else ShellName.bash
41
+
42
+
43
+ # ============================================================================
44
+ # Script Generation
45
+ # ============================================================================
46
+
47
+
48
+ def render_shell_integration_script(shell_name: ShellName) -> str:
49
+ """Render the shell integration script for a specific shell."""
50
+ match shell_name:
51
+ case ShellName.pwsh:
52
+ return _render_pwsh_integration_script()
53
+ case ShellName.bash | ShellName.zsh:
54
+ return _render_posix_shell_integration_script(shell_name)
55
+ case _:
56
+ raise ValueError(f"Unsupported shell: {shell_name}")
57
+
58
+
59
+ def _render_posix_shell_integration_script(
60
+ shell_name: PosixShellName,
61
+ ) -> str:
62
+ """Render shell integration script for POSIX-style shells."""
63
+ return f"""# uvg shell integration for {shell_name.value}
64
+ uvg() {{
65
+ if [ "$1" = "activate" ]; then
66
+ if [ -z "$2" ]; then
67
+ echo "Error: Please specify an environment name." >&2
68
+ return 1
69
+ fi
70
+
71
+ local activation_command
72
+ activation_command="$(command uvg activate --shell {shell_name.value} "$2")" || return 1
73
+ eval "$activation_command"
74
+ return $?
75
+ fi
76
+
77
+ command uvg "$@"
78
+ }}"""
79
+
80
+
81
+ def _render_pwsh_integration_script() -> str:
82
+ """Render shell integration script for Pwsh."""
83
+ return r"""# uvg shell integration for Pwsh
84
+ function uvg {
85
+ if ($args[0] -eq "activate") {
86
+ if ($args.Count -lt 2 -or [string]::IsNullOrWhiteSpace($args[1])) {
87
+ Write-Error "Please specify an environment name."
88
+ $global:LASTEXITCODE = 1
89
+ return
90
+ }
91
+
92
+ $uvgExecutable = (Get-Command uvg -CommandType Application -TotalCount 1).Source
93
+ $activationCommand = & $uvgExecutable activate --shell pwsh $args[1]
94
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($activationCommand)) {
95
+ $global:LASTEXITCODE = 1
96
+ return
97
+ }
98
+
99
+ Invoke-Expression $activationCommand
100
+ return
101
+ }
102
+
103
+ $uvgExecutable = (Get-Command uvg -CommandType Application -TotalCount 1).Source
104
+ & $uvgExecutable @args
105
+ }"""
106
+
107
+
108
+ # ============================================================================
109
+ # Profile Integration
110
+ # ============================================================================
111
+
112
+
113
+ def append_shell_integration_to_profile(
114
+ shell_name: ShellName, profile_path: Path
115
+ ) -> bool:
116
+ """Append shell integration snippet to a shell profile file."""
117
+ initialization_command = build_profile_initialization_command(shell_name)
118
+ profile_snippet = render_profile_initialization_snippet(initialization_command)
119
+
120
+ if profile_path.exists():
121
+ current_profile_contents = profile_path.read_text(encoding="utf-8")
122
+ if (
123
+ PROFILE_SNIPPET_START_MARKER in current_profile_contents
124
+ or initialization_command in current_profile_contents
125
+ ):
126
+ return False
127
+
128
+ profile_path.parent.mkdir(parents=True, exist_ok=True)
129
+ with profile_path.open("a", encoding="utf-8", newline="\n") as profile_file:
130
+ profile_file.write(profile_snippet)
131
+
132
+ return True
133
+
134
+
135
+ def build_profile_initialization_command(shell_name: ShellName) -> str:
136
+ """Build the initialization command for a shell profile."""
137
+ match shell_name:
138
+ case ShellName.pwsh:
139
+ return "Invoke-Expression (uvg init pwsh | Out-String)"
140
+ case ShellName.bash:
141
+ return 'eval "$(uvg init bash)"'
142
+ case ShellName.zsh:
143
+ return 'eval "$(uvg init zsh)"'
144
+ case _:
145
+ raise ValueError(f"Unsupported shell: {shell_name}")
146
+
147
+
148
+ def render_profile_initialization_snippet(initialization_command: str) -> str:
149
+ """Render the profile initialization snippet with markers."""
150
+ return (
151
+ "\n"
152
+ f"{PROFILE_SNIPPET_START_MARKER}\n"
153
+ f"{initialization_command}\n"
154
+ f"{PROFILE_SNIPPET_END_MARKER}\n"
155
+ )
156
+
157
+
158
+ # ============================================================================
159
+ # Activation
160
+ # ============================================================================
161
+
162
+
163
+ def render_activation_command(environment_path: Path, shell_name: ShellName) -> str:
164
+ """Render the command to activate an environment for a specific shell."""
165
+ activation_script_path = build_activation_script_path(environment_path, shell_name)
166
+
167
+ if shell_name in {ShellName.bash, ShellName.zsh}:
168
+ return f"source {shlex.quote(activation_script_path)}"
169
+
170
+ return f". {_quote_pwsh_string_literal(activation_script_path)}"
171
+
172
+
173
+ def build_activation_script_path(environment_path: Path, shell_name: ShellName) -> str:
174
+ """Build the path to the activation script for a shell."""
175
+ scripts_dir = environment_path / ("Scripts" if IS_WINDOWS else "bin")
176
+
177
+ if shell_name in {ShellName.bash, ShellName.zsh}:
178
+ script_path = scripts_dir / "activate"
179
+ return (
180
+ convert_windows_path_to_msys_path(script_path)
181
+ if IS_WINDOWS
182
+ else str(script_path)
183
+ )
184
+
185
+ return str(scripts_dir / "Activate.ps1")
186
+
187
+
188
+ def _quote_pwsh_string_literal(value: str) -> str:
189
+ """Quote a string for Pwsh."""
190
+ return "'" + value.replace("'", "''") + "'"
191
+
192
+
193
+ def convert_windows_path_to_msys_path(path: Path) -> str:
194
+ """Convert C:/style paths to /c/style paths for MSYS-like shells."""
195
+ posix_path = path.as_posix()
196
+ if len(posix_path) >= 3 and posix_path[1:3] == ":/":
197
+ return f"/{posix_path[0].lower()}{posix_path[2:]}"
198
+ return posix_path