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 +53 -0
- uvg-0.1.0/README.md +43 -0
- uvg-0.1.0/pyproject.toml +96 -0
- uvg-0.1.0/src/uvg/__init__.py +9 -0
- uvg-0.1.0/src/uvg/__main__.py +32 -0
- uvg-0.1.0/src/uvg/cli.py +20 -0
- uvg-0.1.0/src/uvg/commands/__init__.py +1 -0
- uvg-0.1.0/src/uvg/commands/activate.py +38 -0
- uvg-0.1.0/src/uvg/commands/create.py +24 -0
- uvg-0.1.0/src/uvg/commands/env/__init__.py +18 -0
- uvg-0.1.0/src/uvg/commands/env/current.py +15 -0
- uvg-0.1.0/src/uvg/commands/env/directory.py +14 -0
- uvg-0.1.0/src/uvg/commands/env/list.py +22 -0
- uvg-0.1.0/src/uvg/commands/init.py +58 -0
- uvg-0.1.0/src/uvg/commands/remove.py +32 -0
- uvg-0.1.0/src/uvg/core/environment.py +242 -0
- uvg-0.1.0/src/uvg/core/errors.py +5 -0
- uvg-0.1.0/src/uvg/core/shell.py +198 -0
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` |
|
uvg-0.1.0/pyproject.toml
ADDED
|
@@ -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,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())
|
uvg-0.1.0/src/uvg/cli.py
ADDED
|
@@ -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,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
|