uvrev 0.1__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.
- uvrev/__init__.py +4 -0
- uvrev/cli.py +222 -0
- uvrev/commands.py +99 -0
- uvrev/constants.py +5 -0
- uvrev/differ.py +140 -0
- uvrev/exceptions.py +1 -0
- uvrev/io.py +25 -0
- uvrev/operations.py +282 -0
- uvrev/parser.py +105 -0
- uvrev/terminal.py +21 -0
- uvrev/types.py +63 -0
- uvrev-0.1.dist-info/METADATA +136 -0
- uvrev-0.1.dist-info/RECORD +15 -0
- uvrev-0.1.dist-info/WHEEL +4 -0
- uvrev-0.1.dist-info/entry_points.txt +2 -0
uvrev/__init__.py
ADDED
uvrev/cli.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from platformdirs import user_data_path
|
|
7
|
+
|
|
8
|
+
from uvrev.exceptions import RevisionNotFoundError
|
|
9
|
+
|
|
10
|
+
from . import constants
|
|
11
|
+
from .commands import uv
|
|
12
|
+
from .operations import (
|
|
13
|
+
add_packages,
|
|
14
|
+
create_env,
|
|
15
|
+
list_installed_packages,
|
|
16
|
+
list_projects,
|
|
17
|
+
remove_packages,
|
|
18
|
+
restore_revision,
|
|
19
|
+
show_history,
|
|
20
|
+
upgrade_packages,
|
|
21
|
+
)
|
|
22
|
+
from .terminal import fatal_error, success, warning
|
|
23
|
+
from .types import Revision, UVRevProject
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_base_dir() -> Path:
|
|
27
|
+
"""Get the base directory for uvrev projects.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Path UVREV_BASE or user data path for uvrev
|
|
31
|
+
"""
|
|
32
|
+
if custom_base := os.getenv("UVREV_BASE"):
|
|
33
|
+
base = Path(custom_base)
|
|
34
|
+
else:
|
|
35
|
+
base = user_data_path("uvrev")
|
|
36
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
return base
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def env_option_callback(
|
|
41
|
+
ctx: click.Context | None, param: click.Parameter | None, value: str | None
|
|
42
|
+
) -> UVRevProject:
|
|
43
|
+
if value is None and (venv := os.getenv("VIRTUAL_ENV")) and venv != sys.prefix:
|
|
44
|
+
proj = UVRevProject(Path(venv).parent)
|
|
45
|
+
if proj.exists():
|
|
46
|
+
return proj
|
|
47
|
+
else:
|
|
48
|
+
warning(
|
|
49
|
+
f"Currently activated VIRTUAL_ENV ({venv}) is not a uvrev env. Ignoring."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
name = value or constants.DEFAULT_ENV
|
|
53
|
+
proj = UVRevProject(get_base_dir() / name)
|
|
54
|
+
|
|
55
|
+
if not proj.exists():
|
|
56
|
+
raise click.BadParameter(
|
|
57
|
+
f"❌ Project '{name}' does not exist. Create it with 'uvrev env create'",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return proj
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def env_option(f):
|
|
64
|
+
"""Reusable option decorator for environment selection with project lookup"""
|
|
65
|
+
|
|
66
|
+
return click.option(
|
|
67
|
+
"--env",
|
|
68
|
+
"-e",
|
|
69
|
+
"project",
|
|
70
|
+
default=None,
|
|
71
|
+
show_default=constants.DEFAULT_ENV,
|
|
72
|
+
callback=env_option_callback,
|
|
73
|
+
help="Project/environment name (env: UVREV_ENV, default: base)",
|
|
74
|
+
)(f)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@click.group()
|
|
78
|
+
@click.version_option()
|
|
79
|
+
def cli() -> None:
|
|
80
|
+
"""uvrev - UV package manager with conda-style revision tracking
|
|
81
|
+
|
|
82
|
+
Manage UV projects with full revision history and easy rollback capabilities.
|
|
83
|
+
Each package change is tracked in git with detailed changelogs.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@cli.group()
|
|
88
|
+
def env() -> None:
|
|
89
|
+
"""Manage environments"""
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@env.command("create")
|
|
94
|
+
@click.argument("name", required=False)
|
|
95
|
+
@click.option(
|
|
96
|
+
"--python",
|
|
97
|
+
default=constants.DEFAULT_PYTHON,
|
|
98
|
+
show_default=True,
|
|
99
|
+
help="Python version",
|
|
100
|
+
)
|
|
101
|
+
def env_create(name: str | None, python: str) -> None:
|
|
102
|
+
"""Create a new UV project with revision tracking"""
|
|
103
|
+
project_name = name or constants.DEFAULT_ENV
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
project = create_env(get_base_dir(), project_name, python)
|
|
107
|
+
success(f"Project '{project_name}' created at {project.project_path}")
|
|
108
|
+
except FileExistsError as e:
|
|
109
|
+
fatal_error(str(e))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@env.command("list")
|
|
113
|
+
def env_list() -> None:
|
|
114
|
+
"""List all UV projects"""
|
|
115
|
+
projects = list_projects(get_base_dir())
|
|
116
|
+
|
|
117
|
+
if not projects:
|
|
118
|
+
click.echo("No projects found. Create one with 'uvrev env create'")
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
click.echo("Projects:")
|
|
122
|
+
for proj in projects:
|
|
123
|
+
click.echo(f" • {proj}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@cli.command()
|
|
127
|
+
@click.argument("packages", nargs=-1, required=True)
|
|
128
|
+
@env_option
|
|
129
|
+
def add(project: UVRevProject, packages: tuple[str, ...]) -> None:
|
|
130
|
+
"""Add packages to the current project"""
|
|
131
|
+
rev_num = add_packages(project, packages)
|
|
132
|
+
if rev_num:
|
|
133
|
+
success(f"Created revision {rev_num}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@cli.command()
|
|
137
|
+
@click.argument("packages", nargs=-1, required=True)
|
|
138
|
+
@env_option
|
|
139
|
+
def remove(project: UVRevProject, packages: tuple[str, ...]) -> None:
|
|
140
|
+
"""Remove packages from the current project"""
|
|
141
|
+
rev_num = remove_packages(project, packages)
|
|
142
|
+
success(f"Removed {' '.join(packages)}. Created revision {rev_num}.")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@cli.command()
|
|
146
|
+
@env_option
|
|
147
|
+
def sync(project: UVRevProject) -> None:
|
|
148
|
+
"""Sync project dependencies"""
|
|
149
|
+
uv("sync", "--frozen", project=project)
|
|
150
|
+
success("Sync complete.")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@cli.command()
|
|
154
|
+
@click.argument("packages", nargs=-1)
|
|
155
|
+
@env_option
|
|
156
|
+
def upgrade(project: UVRevProject, packages: tuple[str, ...]) -> None:
|
|
157
|
+
"""Upgrade packages or all dependencies if no packages specified
|
|
158
|
+
|
|
159
|
+
Examples:
|
|
160
|
+
uvrev upgrade # Upgrade all packages
|
|
161
|
+
uvrev upgrade pandas # Upgrade only pandas
|
|
162
|
+
"""
|
|
163
|
+
rev_num = upgrade_packages(project, packages)
|
|
164
|
+
if packages:
|
|
165
|
+
success(f"Upgraded {len(packages)} package(s), created revision {rev_num}")
|
|
166
|
+
else:
|
|
167
|
+
success(f"Upgraded all packages, created revision {rev_num}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@cli.command("list")
|
|
171
|
+
@env_option
|
|
172
|
+
def list_packages(project: UVRevProject) -> None:
|
|
173
|
+
"""List installed packages in the current environment"""
|
|
174
|
+
all_packages = list_installed_packages(project)
|
|
175
|
+
|
|
176
|
+
click.echo("Installed packages:")
|
|
177
|
+
for pkg, version, in_pyproject_toml in all_packages:
|
|
178
|
+
click.secho(
|
|
179
|
+
f" • {pkg}=={version} {'[dep]' if in_pyproject_toml else ''}",
|
|
180
|
+
bold=in_pyproject_toml,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@cli.command()
|
|
185
|
+
@env_option
|
|
186
|
+
def history(project: UVRevProject) -> None:
|
|
187
|
+
"""Show revision history for the current project"""
|
|
188
|
+
output = show_history(project)
|
|
189
|
+
click.echo(output)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@cli.command()
|
|
193
|
+
@click.argument("revision", type=int)
|
|
194
|
+
@env_option
|
|
195
|
+
def restore(project: UVRevProject, revision: Revision) -> None:
|
|
196
|
+
"""Restore project to a specific revision"""
|
|
197
|
+
try:
|
|
198
|
+
restore_revision(project, revision)
|
|
199
|
+
success(f"Restored to revision {revision}")
|
|
200
|
+
except RevisionNotFoundError as e:
|
|
201
|
+
fatal_error(str(e))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@cli.command()
|
|
205
|
+
@env_option
|
|
206
|
+
def path(project: UVRevProject) -> None:
|
|
207
|
+
"""Show the path to the current project"""
|
|
208
|
+
click.echo(project.project_path)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@cli.command(context_settings={"ignore_unknown_options": True})
|
|
212
|
+
@click.argument("run_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
|
213
|
+
@env_option
|
|
214
|
+
def run(project: UVRevProject, run_args: tuple[str, ...]) -> None:
|
|
215
|
+
"""Run commands in the project using uv run with locked dependencies
|
|
216
|
+
|
|
217
|
+
Examples:
|
|
218
|
+
uvrev run python script.py
|
|
219
|
+
uvrev run pytest
|
|
220
|
+
uvrev run -- myapp --help
|
|
221
|
+
"""
|
|
222
|
+
uv("run", "--locked", *run_args, project=project)
|
uvrev/commands.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import subprocess
|
|
3
|
+
from functools import cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal, overload
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from coverage.core import os
|
|
9
|
+
|
|
10
|
+
from uvrev.types import SubprocessFailure, UVRevProject
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def show_cmd(cmd: tuple[str, ...]) -> None:
|
|
14
|
+
"""Display command being executed"""
|
|
15
|
+
click.echo(
|
|
16
|
+
click.style("$ ", fg="red", bold=True)
|
|
17
|
+
+ click.style(shlex.join(cmd), fg="magenta")
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@cache
|
|
22
|
+
def show_cwd_change(project_path: Path) -> None:
|
|
23
|
+
"""Show cd command once per project"""
|
|
24
|
+
show_cmd(("cd", str(project_path)))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@overload
|
|
28
|
+
def git(
|
|
29
|
+
*args: str,
|
|
30
|
+
project: UVRevProject,
|
|
31
|
+
capture_output: Literal[False] = False,
|
|
32
|
+
) -> None: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@overload
|
|
36
|
+
def git(
|
|
37
|
+
*args: str,
|
|
38
|
+
project: UVRevProject,
|
|
39
|
+
capture_output: Literal[True],
|
|
40
|
+
) -> str: ...
|
|
41
|
+
def git(
|
|
42
|
+
*args: str,
|
|
43
|
+
project: UVRevProject,
|
|
44
|
+
capture_output: bool = False,
|
|
45
|
+
) -> str | None:
|
|
46
|
+
"""
|
|
47
|
+
Execute git command in project directory
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
*args: Git command arguments
|
|
51
|
+
project: Project to run command in
|
|
52
|
+
capture_output: If True, capture and return stdout
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Command stdout if capture_output=True, empty string otherwise
|
|
56
|
+
"""
|
|
57
|
+
show_cwd_change(project.project_path)
|
|
58
|
+
cmd = ("git", *args)
|
|
59
|
+
show_cmd(cmd)
|
|
60
|
+
|
|
61
|
+
stdout = subprocess.PIPE if capture_output else None
|
|
62
|
+
try:
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
cmd,
|
|
65
|
+
cwd=project.project_path,
|
|
66
|
+
stdout=stdout,
|
|
67
|
+
text=True,
|
|
68
|
+
check=True,
|
|
69
|
+
)
|
|
70
|
+
except subprocess.CalledProcessError as e:
|
|
71
|
+
raise SubprocessFailure(e) from e
|
|
72
|
+
if capture_output:
|
|
73
|
+
return result.stdout
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def uv(*args: str, project: UVRevProject) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Execute uv command in project directory
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
*args: UV command arguments
|
|
83
|
+
project: Project to run command in
|
|
84
|
+
capture_output: If True, capture and return stdout
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Command stdout if capture_output=True, empty string otherwise
|
|
88
|
+
"""
|
|
89
|
+
show_cwd_change(project.project_path)
|
|
90
|
+
cmd = ("uv", *args)
|
|
91
|
+
show_cmd(cmd)
|
|
92
|
+
env = os.environ.copy()
|
|
93
|
+
if "VIRTUAL_ENV" in env:
|
|
94
|
+
# disable VIRTUAL_ENV warning
|
|
95
|
+
del env["VIRTUAL_ENV"]
|
|
96
|
+
try:
|
|
97
|
+
subprocess.run(cmd, cwd=project.project_path, check=True, env=env)
|
|
98
|
+
except subprocess.CalledProcessError as e:
|
|
99
|
+
raise SubprocessFailure(e) from e
|
uvrev/constants.py
ADDED
uvrev/differ.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
|
|
3
|
+
from packaging.specifiers import SpecifierSet
|
|
4
|
+
from packaging.utils import NormalizedName
|
|
5
|
+
from packaging.version import Version
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def version2str(ver: Version | SpecifierSet | str | None) -> str:
|
|
9
|
+
"""
|
|
10
|
+
Convert a version to a string
|
|
11
|
+
|
|
12
|
+
>> version2str(None)
|
|
13
|
+
''
|
|
14
|
+
>>version2str(SpecifierSet(">=1.2"))
|
|
15
|
+
'>=1.2'
|
|
16
|
+
>> version2str(Version("1.2"))
|
|
17
|
+
'==1.2'
|
|
18
|
+
"""
|
|
19
|
+
if ver is None:
|
|
20
|
+
return ""
|
|
21
|
+
elif isinstance(ver, Version):
|
|
22
|
+
return f"=={ver}"
|
|
23
|
+
return str(ver)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def compute_diff(
|
|
27
|
+
old_items: Mapping[NormalizedName, Version | SpecifierSet | None | str],
|
|
28
|
+
new_items: Mapping[NormalizedName, Version | SpecifierSet | None | str],
|
|
29
|
+
) -> tuple[str, ...]:
|
|
30
|
+
"""Compute diff between two package states.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
old_items: Previous package state (name -> version string).
|
|
34
|
+
new_items: New package state (name -> version string).
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Sorted list of change strings (additions, removals, updates).
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
>>> old = {'pandas': '>=1.0', 'numpy': ''}
|
|
41
|
+
>>> new = {'pandas': '>=2.0', 'scipy': '>=1.5'}
|
|
42
|
+
>>> diff = compute_diff(old, new)
|
|
43
|
+
>>> diff[0] # Added scipy
|
|
44
|
+
' +scipy>=1.5'
|
|
45
|
+
>>> diff[1] # Removed numpy
|
|
46
|
+
' -numpy'
|
|
47
|
+
>>> diff[2] # Updated pandas
|
|
48
|
+
' ^pandas>=1.0 → pandas>=2.0'
|
|
49
|
+
|
|
50
|
+
>>> # No changes case
|
|
51
|
+
>>> old = {'pandas': '>=1.0'}
|
|
52
|
+
>>> new = {'pandas': '>=1.0'}
|
|
53
|
+
>>> compute_diff(old, new)
|
|
54
|
+
()
|
|
55
|
+
|
|
56
|
+
>>> # Only additions
|
|
57
|
+
>>> old = {}
|
|
58
|
+
>>> new = {'pandas': '>=2.0', 'numpy': ''}
|
|
59
|
+
>>> diff = compute_diff(old, new)
|
|
60
|
+
>>> len(diff)
|
|
61
|
+
2
|
|
62
|
+
>>> diff[0]
|
|
63
|
+
' +numpy'
|
|
64
|
+
>>> diff[1]
|
|
65
|
+
' +pandas>=2.0'
|
|
66
|
+
"""
|
|
67
|
+
changes = []
|
|
68
|
+
|
|
69
|
+
# Removed
|
|
70
|
+
for pkg, ver in old_items.items():
|
|
71
|
+
if pkg not in new_items:
|
|
72
|
+
changes.append(f" -{pkg}{version2str(ver)}")
|
|
73
|
+
|
|
74
|
+
# Added
|
|
75
|
+
for pkg, ver in new_items.items():
|
|
76
|
+
if pkg not in old_items:
|
|
77
|
+
changes.append(f" +{pkg}{version2str(ver)}")
|
|
78
|
+
|
|
79
|
+
# Updated
|
|
80
|
+
for pkg, ver in new_items.items():
|
|
81
|
+
if pkg in old_items and old_items[pkg] != ver:
|
|
82
|
+
changes.append(
|
|
83
|
+
f" ^{pkg}{version2str(old_items[pkg])} → {pkg}{version2str(ver)}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return tuple(sorted(changes))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def create_changelog(
|
|
90
|
+
old_deps: Mapping[NormalizedName, SpecifierSet | None],
|
|
91
|
+
new_deps: Mapping[NormalizedName, SpecifierSet | None],
|
|
92
|
+
old_packages: Mapping[NormalizedName, Version],
|
|
93
|
+
new_packages: Mapping[NormalizedName, Version],
|
|
94
|
+
) -> str:
|
|
95
|
+
"""Create a formatted changelog from dependency and lockfile diffs.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
old_deps: Previous dependency constraints.
|
|
99
|
+
new_deps: New dependency constraints.
|
|
100
|
+
old_packages: Previous pinned package versions.
|
|
101
|
+
new_packages: New pinned package versions.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Formatted changelog string with Dependencies and Pinned Versions sections.
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
>>> old_d = {'pandas': None}
|
|
108
|
+
>>> new_d = {'pandas': None, 'numpy': None}
|
|
109
|
+
>>> old_p = {'pandas': Version('1.0.0')}
|
|
110
|
+
>>> new_p = {'pandas': Version('2.0.0'), 'numpy': Version('1.24.0')}
|
|
111
|
+
>>> print(create_changelog(old_d, new_d, old_p, new_p))
|
|
112
|
+
<BLANKLINE>
|
|
113
|
+
Dependencies:
|
|
114
|
+
+numpy
|
|
115
|
+
Pinned Versions:
|
|
116
|
+
+numpy==1.24.0
|
|
117
|
+
^pandas==1.0.0 → pandas==2.0.0
|
|
118
|
+
<BLANKLINE>
|
|
119
|
+
|
|
120
|
+
>>> # No changes case
|
|
121
|
+
>>> old_d = {'pandas': None}
|
|
122
|
+
>>> new_d = {'pandas': None}
|
|
123
|
+
>>> old_p = {'pandas': Version('1.0.0')}
|
|
124
|
+
>>> new_p = {'pandas': Version('1.0.0')}
|
|
125
|
+
>>> print(create_changelog(old_d, new_d, old_p, new_p))
|
|
126
|
+
(no changes)
|
|
127
|
+
"""
|
|
128
|
+
# Convert to string representation for diff
|
|
129
|
+
deps_diff = compute_diff(old_deps, new_deps)
|
|
130
|
+
lock_diff = compute_diff(old_packages, new_packages)
|
|
131
|
+
result = ""
|
|
132
|
+
if deps_diff:
|
|
133
|
+
result = "\n Dependencies:\n"
|
|
134
|
+
result += "\n".join(deps_diff)
|
|
135
|
+
if lock_diff:
|
|
136
|
+
result += "\n Pinned Versions:\n"
|
|
137
|
+
result += "\n".join(lock_diff) + "\n"
|
|
138
|
+
if result:
|
|
139
|
+
return result
|
|
140
|
+
return "(no changes)"
|
uvrev/exceptions.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
class RevisionNotFoundError(Exception): ...
|
uvrev/io.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import tomlkit
|
|
2
|
+
|
|
3
|
+
from .types import UVRevProject
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def read_pyproject(project: UVRevProject) -> tomlkit.TOMLDocument:
|
|
7
|
+
"""Read pyproject.toml using tomlkit.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
project: Project to read from.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
Dictionary containing pyproject.toml content.
|
|
14
|
+
"""
|
|
15
|
+
return tomlkit.loads(project.pyproject_path.read_text())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def write_pyproject(project: UVRevProject, data: tomlkit.TOMLDocument) -> None:
|
|
19
|
+
"""Write pyproject.toml using tomlkit to preserve comments.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
project: Project to write to.
|
|
23
|
+
data: Dictionary to write as TOML.
|
|
24
|
+
"""
|
|
25
|
+
project.pyproject_path.write_text(tomlkit.dumps(data))
|
uvrev/operations.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from packaging.utils import NormalizedName
|
|
5
|
+
from packaging.version import Version
|
|
6
|
+
|
|
7
|
+
from . import constants
|
|
8
|
+
from .commands import git, uv
|
|
9
|
+
from .differ import create_changelog
|
|
10
|
+
from .exceptions import RevisionNotFoundError
|
|
11
|
+
from .io import read_pyproject, write_pyproject
|
|
12
|
+
from .parser import parse_pyproject_deps, parse_uv_lock_packages
|
|
13
|
+
from .types import Revision, SubprocessFailure, UVRevProject
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_env(base_dir: Path, name: str, python: str) -> UVRevProject:
|
|
17
|
+
"""Create a new UV project with revision tracking.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
base_dir: Base directory to create project in.
|
|
21
|
+
name: Name of the project.
|
|
22
|
+
python: Python version to use.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Created UVRevProject instance.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ValueError: If project already exists.
|
|
29
|
+
"""
|
|
30
|
+
target = base_dir.joinpath(name)
|
|
31
|
+
if target.exists():
|
|
32
|
+
raise FileExistsError(f"Folder for project '{name}' ({target}) already exists.")
|
|
33
|
+
project = UVRevProject(target)
|
|
34
|
+
target.mkdir(parents=True)
|
|
35
|
+
# Create project using uv
|
|
36
|
+
uv(
|
|
37
|
+
"init",
|
|
38
|
+
"--python",
|
|
39
|
+
python,
|
|
40
|
+
"--no-package",
|
|
41
|
+
"--no-readme",
|
|
42
|
+
"--no-description",
|
|
43
|
+
"--no-workspace",
|
|
44
|
+
"--name",
|
|
45
|
+
f"uvrev-venv-{name}",
|
|
46
|
+
".",
|
|
47
|
+
project=project,
|
|
48
|
+
)
|
|
49
|
+
target.joinpath("main.py").unlink(missing_ok=True)
|
|
50
|
+
# Configure pyproject.toml
|
|
51
|
+
data = read_pyproject(project)
|
|
52
|
+
data.setdefault("tool", {}).setdefault("uv", {})["package"] = False
|
|
53
|
+
data.setdefault("project", {})["version"] = "0"
|
|
54
|
+
write_pyproject(project, data)
|
|
55
|
+
uv("sync", project=project)
|
|
56
|
+
|
|
57
|
+
# Create initial revision
|
|
58
|
+
git("add", ".", project=project)
|
|
59
|
+
git("commit", "-m", constants.INITIAL_COMMIT_MSG, project=project)
|
|
60
|
+
git("tag", "rev_0", project=project)
|
|
61
|
+
|
|
62
|
+
return project
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def list_projects(base_dir: Path) -> tuple[str, ...]:
|
|
66
|
+
"""List all projects.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
base_dir: Base directory containing projects.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
List of project names.
|
|
73
|
+
"""
|
|
74
|
+
if not base_dir.exists():
|
|
75
|
+
return ()
|
|
76
|
+
return tuple(
|
|
77
|
+
p.name
|
|
78
|
+
for p in base_dir.iterdir()
|
|
79
|
+
if p.is_dir() and (p / "pyproject.toml").exists()
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_next_revision_number(project: UVRevProject) -> Revision:
|
|
84
|
+
"""Get the next revision number.
|
|
85
|
+
|
|
86
|
+
Uses git tag -l to list all numeric tags, sorts them, and returns the highest + 1.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
project: Project to get revision number for.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Next revision number (0 if no revisions exist).
|
|
93
|
+
"""
|
|
94
|
+
output = git(
|
|
95
|
+
"tag",
|
|
96
|
+
"--list",
|
|
97
|
+
"rev_[0-9]*",
|
|
98
|
+
project=project,
|
|
99
|
+
capture_output=True,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
last_rev = 0
|
|
103
|
+
|
|
104
|
+
for rev in output.splitlines(keepends=False):
|
|
105
|
+
with contextlib.suppress(ValueError):
|
|
106
|
+
last_rev = max(last_rev, int(rev.removeprefix("rev_")))
|
|
107
|
+
|
|
108
|
+
return Revision(last_rev + 1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def create_revision(
|
|
112
|
+
project: UVRevProject, message: str = "Update packages"
|
|
113
|
+
) -> Revision | None:
|
|
114
|
+
"""Create a new revision (commit + tag).
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
project: Project to create revision in.
|
|
118
|
+
message: Commit message.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
New revision number or None if nothing was changed
|
|
122
|
+
"""
|
|
123
|
+
# Get previous and current state
|
|
124
|
+
old_pyproject = git(
|
|
125
|
+
"show", "HEAD:pyproject.toml", project=project, capture_output=True
|
|
126
|
+
)
|
|
127
|
+
old_lock = git("show", "HEAD:uv.lock", project=project, capture_output=True)
|
|
128
|
+
current_pyproject = project.pyproject_path.read_text()
|
|
129
|
+
current_lock = project.lock_path.read_text()
|
|
130
|
+
if old_pyproject == current_pyproject and old_lock == current_lock:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
old_deps = parse_pyproject_deps(old_pyproject)
|
|
134
|
+
old_packages = parse_uv_lock_packages(old_lock)
|
|
135
|
+
new_deps = parse_pyproject_deps(current_pyproject)
|
|
136
|
+
new_packages = parse_uv_lock_packages(current_lock)
|
|
137
|
+
|
|
138
|
+
# Build changelog
|
|
139
|
+
changelog = create_changelog(old_deps, new_deps, old_packages, new_packages)
|
|
140
|
+
|
|
141
|
+
# Get next revision number (which is also the version)
|
|
142
|
+
rev_num = get_next_revision_number(project)
|
|
143
|
+
|
|
144
|
+
# Commit changes
|
|
145
|
+
git("add", "pyproject.toml", "uv.lock", project=project)
|
|
146
|
+
|
|
147
|
+
commit_msg = f"(rev {rev_num}) {message}\n{changelog}"
|
|
148
|
+
git("commit", "-m", commit_msg, project=project)
|
|
149
|
+
|
|
150
|
+
# Create lightweight tag
|
|
151
|
+
git("tag", f"rev_{rev_num}", project=project)
|
|
152
|
+
|
|
153
|
+
return Revision(rev_num)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def restore_revision(project: UVRevProject, rev_num: Revision) -> None:
|
|
157
|
+
"""Restore to a specific revision.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
project: Project to restore.
|
|
161
|
+
rev_num: Revision number to restore to.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
ValueError: If revision does not exist.
|
|
165
|
+
"""
|
|
166
|
+
tag_name = f"rev_{rev_num}"
|
|
167
|
+
|
|
168
|
+
# Check if tag exists
|
|
169
|
+
try:
|
|
170
|
+
git("rev-parse", tag_name, project=project, capture_output=True)
|
|
171
|
+
except SubprocessFailure:
|
|
172
|
+
raise RevisionNotFoundError(f"Revision {rev_num} does not exist") from None
|
|
173
|
+
|
|
174
|
+
# Checkout files from that revision
|
|
175
|
+
git("checkout", tag_name, "--", "pyproject.toml", "uv.lock", project=project)
|
|
176
|
+
|
|
177
|
+
# Run uv sync
|
|
178
|
+
uv("sync", "--locked", project=project)
|
|
179
|
+
|
|
180
|
+
# Create new revision for the restore
|
|
181
|
+
create_revision(project, f"Restored to revision {rev_num}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def list_installed_packages(
|
|
185
|
+
project: UVRevProject,
|
|
186
|
+
) -> tuple[tuple[NormalizedName, Version, bool], ...]:
|
|
187
|
+
"""Get installed packages and which are in pyproject.toml.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
project: Project to list packages for.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Tuple of (pkg, version, defined_in_pyproject.toml)
|
|
194
|
+
"""
|
|
195
|
+
all_packages = parse_uv_lock_packages(project.lock_path.read_text())
|
|
196
|
+
pyproject_deps = set(
|
|
197
|
+
parse_pyproject_deps(project.pyproject_path.read_text()).keys()
|
|
198
|
+
)
|
|
199
|
+
return tuple(
|
|
200
|
+
(pkg, version, pkg in pyproject_deps)
|
|
201
|
+
for pkg, version in sorted(all_packages.items())
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def add_packages(project: UVRevProject, packages: tuple[str, ...]) -> int | None:
|
|
206
|
+
"""Add packages to project.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
project: Project to add packages to.
|
|
210
|
+
packages: Package names to add.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
New revision number.
|
|
214
|
+
"""
|
|
215
|
+
uv("add", "--raw", *packages, project=project)
|
|
216
|
+
return create_revision(project, f"Added: {', '.join(packages)}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
RemoveCount = int
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def remove_packages(
|
|
223
|
+
project: UVRevProject, packages: tuple[str, ...]
|
|
224
|
+
) -> Revision | None:
|
|
225
|
+
"""Remove packages from project.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
project: Project to remove packages from.
|
|
229
|
+
packages: Package names to remove.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Tuple of (new_revision_number, number_of_packages_removed).
|
|
233
|
+
"""
|
|
234
|
+
# Use uv to remove packages
|
|
235
|
+
uv("remove", *packages, project=project)
|
|
236
|
+
|
|
237
|
+
# Create revision
|
|
238
|
+
rev_num = create_revision(project, f"Removed: {', '.join(packages)}")
|
|
239
|
+
return rev_num
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def show_history(project: UVRevProject) -> str:
|
|
243
|
+
"""Get revision history.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
project: Project to get history for.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Formatted git log output.
|
|
250
|
+
"""
|
|
251
|
+
return git(
|
|
252
|
+
"log",
|
|
253
|
+
"--tags",
|
|
254
|
+
"--color=always",
|
|
255
|
+
"--format=%ai %C(bold)%C(green)%s%C(reset)%n%b",
|
|
256
|
+
"--reverse",
|
|
257
|
+
project=project,
|
|
258
|
+
capture_output=True,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def upgrade_packages(
|
|
263
|
+
project: UVRevProject, packages: tuple[str, ...]
|
|
264
|
+
) -> Revision | None:
|
|
265
|
+
"""Upgrade packages or all if no packages specified.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
project: Project to upgrade packages in.
|
|
269
|
+
packages: Package names to upgrade (empty for all).
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
New revision number.
|
|
273
|
+
"""
|
|
274
|
+
if packages:
|
|
275
|
+
for pkg in packages:
|
|
276
|
+
uv("sync", "--upgrade-package", pkg, project=project)
|
|
277
|
+
message = f"Upgraded: {', '.join(packages)}"
|
|
278
|
+
else:
|
|
279
|
+
uv("sync", "--upgrade", project=project)
|
|
280
|
+
message = "Upgraded all packages"
|
|
281
|
+
|
|
282
|
+
return create_revision(project, message)
|
uvrev/parser.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
|
|
3
|
+
from packaging.specifiers import SpecifierSet
|
|
4
|
+
from packaging.utils import NormalizedName, canonicalize_name
|
|
5
|
+
from packaging.version import Version
|
|
6
|
+
|
|
7
|
+
from .types import NormalizedRequirement
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_pyproject_deps(
|
|
11
|
+
toml_content: str,
|
|
12
|
+
) -> dict[NormalizedName, SpecifierSet | None]:
|
|
13
|
+
"""Parse dependencies from pyproject.toml content.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
toml_content: TOML file content as string.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Dictionary mapping canonical package names to their version specifiers.
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
>>> content = '''
|
|
23
|
+
... [project]
|
|
24
|
+
... dependencies = ["pandas>=2.0", "numpy", "scipy~=1.5.0"]
|
|
25
|
+
... '''
|
|
26
|
+
>>> deps = parse_pyproject_deps(content)
|
|
27
|
+
>>> 'pandas' in deps
|
|
28
|
+
True
|
|
29
|
+
>>> deps['numpy'] is None
|
|
30
|
+
True
|
|
31
|
+
>>> str(deps['pandas'])
|
|
32
|
+
'>=2.0'
|
|
33
|
+
>>> str(deps['scipy'])
|
|
34
|
+
'~=1.5.0'
|
|
35
|
+
|
|
36
|
+
>>> # Empty content
|
|
37
|
+
>>> parse_pyproject_deps('')
|
|
38
|
+
{}
|
|
39
|
+
|
|
40
|
+
>>> # No dependencies
|
|
41
|
+
>>> parse_pyproject_deps('[project]\\nname = "test"')
|
|
42
|
+
{}
|
|
43
|
+
"""
|
|
44
|
+
if not toml_content.strip():
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
data = tomllib.loads(toml_content)
|
|
48
|
+
deps = {}
|
|
49
|
+
|
|
50
|
+
for dep_str in data.get("project", {}).get("dependencies", []):
|
|
51
|
+
req = NormalizedRequirement(dep_str)
|
|
52
|
+
deps[req.name] = req.specifier if req.specifier else None
|
|
53
|
+
|
|
54
|
+
return deps
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_uv_lock_packages(toml_content: str) -> dict[NormalizedName, Version]:
|
|
58
|
+
"""Parse packages from uv.lock content.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
toml_content: TOML file content as string.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Dictionary mapping canonical package names to their installed versions.
|
|
65
|
+
|
|
66
|
+
Examples:
|
|
67
|
+
>>> content = '''
|
|
68
|
+
... [[package]]
|
|
69
|
+
... name = "pandas"
|
|
70
|
+
... version = "2.0.0"
|
|
71
|
+
...
|
|
72
|
+
... [[package]]
|
|
73
|
+
... name = "NumPy"
|
|
74
|
+
... version = "1.24.3"
|
|
75
|
+
...
|
|
76
|
+
... [[package]]
|
|
77
|
+
... name = "ignored"
|
|
78
|
+
... '''
|
|
79
|
+
>>> pkgs = parse_uv_lock_packages(content)
|
|
80
|
+
>>> 'pandas' in pkgs
|
|
81
|
+
True
|
|
82
|
+
>>> 'numpy' in pkgs
|
|
83
|
+
True
|
|
84
|
+
>>> str(pkgs['pandas'])
|
|
85
|
+
'2.0.0'
|
|
86
|
+
>>> str(pkgs['numpy'])
|
|
87
|
+
'1.24.3'
|
|
88
|
+
>>> "ignore" not in pkgs
|
|
89
|
+
True
|
|
90
|
+
>>> # Empty content
|
|
91
|
+
>>> parse_uv_lock_packages('')
|
|
92
|
+
{}
|
|
93
|
+
"""
|
|
94
|
+
if not toml_content.strip():
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
data = tomllib.loads(toml_content)
|
|
98
|
+
packages = {}
|
|
99
|
+
|
|
100
|
+
for pkg in data.get("package", []):
|
|
101
|
+
if "name" in pkg and "version" in pkg:
|
|
102
|
+
canonical_name = canonicalize_name(pkg["name"])
|
|
103
|
+
packages[canonical_name] = Version(pkg["version"])
|
|
104
|
+
|
|
105
|
+
return packages
|
uvrev/terminal.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import NoReturn
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def success(msg: str) -> None:
|
|
8
|
+
click.secho(f"✅ {msg}", fg="green", err=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def warning(msg: str) -> None:
|
|
12
|
+
click.secho(f"⚠️ {msg}", fg="yellow", err=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def error(msg: str) -> None:
|
|
16
|
+
click.secho(f"❌ {msg}", fg="red", err=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fatal_error(msg: str, exit_code: int = 1) -> NoReturn:
|
|
20
|
+
error(msg)
|
|
21
|
+
sys.exit(exit_code)
|
uvrev/types.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import subprocess
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import NewType
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from packaging.requirements import Requirement
|
|
10
|
+
from packaging.utils import NormalizedName, canonicalize_name
|
|
11
|
+
|
|
12
|
+
Revision = NewType("Revision", int)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class UVRevProject:
|
|
17
|
+
"""Represents a UV project with revision tracking"""
|
|
18
|
+
|
|
19
|
+
project_path: Path
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def pyproject_path(self) -> Path:
|
|
23
|
+
return self.project_path / "pyproject.toml"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def lock_path(self) -> Path:
|
|
27
|
+
return self.project_path / "uv.lock"
|
|
28
|
+
|
|
29
|
+
def exists(self) -> bool:
|
|
30
|
+
"""
|
|
31
|
+
Check if project exists
|
|
32
|
+
|
|
33
|
+
>>> import tempfile
|
|
34
|
+
>>> with tempfile.TemporaryDirectory() as td:
|
|
35
|
+
... p = UVRevProject(Path(td))
|
|
36
|
+
... p.exists()
|
|
37
|
+
False
|
|
38
|
+
"""
|
|
39
|
+
if not self.project_path.exists() or not self.pyproject_path.exists():
|
|
40
|
+
return False
|
|
41
|
+
data = tomllib.loads(self.pyproject_path.read_text())
|
|
42
|
+
return data.get("tool", {}).get("uv", {}).get("package") is False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NormalizedRequirement(Requirement):
|
|
46
|
+
"""Requirement with normalized name"""
|
|
47
|
+
|
|
48
|
+
name: NormalizedName
|
|
49
|
+
|
|
50
|
+
def __init__(self, requirement_string: str):
|
|
51
|
+
super().__init__(requirement_string)
|
|
52
|
+
self.name = canonicalize_name(self.name)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SubprocessFailure(click.ClickException):
|
|
56
|
+
"""
|
|
57
|
+
SubprocessFailure Exception that will quit the program without stacktrace
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, error: subprocess.CalledProcessError) -> None:
|
|
61
|
+
if isinstance(error.cmd, tuple): # pragma: no branch
|
|
62
|
+
error.cmd = shlex.join(error.cmd)
|
|
63
|
+
super().__init__(str(error))
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uvrev
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Summary: uv env manager to enable conda-style revision tracking
|
|
5
|
+
Author-email: Carli* Freudenberg <kound@posteo.de>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Natural Language :: English
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
20
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: click>=8
|
|
25
|
+
Requires-Dist: platformdirs>=4
|
|
26
|
+
Requires-Dist: tomlkit>=0.13
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# uvrev
|
|
30
|
+
|
|
31
|
+
**uvrev** is a CLI tool that wraps the **uv** package manager and adds **git-based, conda-style revision tracking** for Python environments.
|
|
32
|
+
|
|
33
|
+
It allows you to manage UV projects as isolated environments with a full, inspectable revision history. Every package change (add, remove, update) creates a new Git commit and tag, making it easy to understand what changed and to roll back to any previous state.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Overview
|
|
38
|
+
|
|
39
|
+
- Manage UV environments with automatic revision tracking
|
|
40
|
+
- Track dependency changes over time using Git
|
|
41
|
+
- Restore environments to any previous revision
|
|
42
|
+
- Inspect package history in a familiar conda-style format
|
|
43
|
+
|
|
44
|
+
Each environment is stored as a Git repository. Dependency changes are committed automatically, providing a complete and auditable history of your environment state.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
### Prerequisites
|
|
51
|
+
|
|
52
|
+
- `git`
|
|
53
|
+
- `uv`
|
|
54
|
+
|
|
55
|
+
### Install with uv
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv tool install uvrev
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
After installation, ensure that `uvrev` is available on your `PATH`:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uvrev --help
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Key Features
|
|
70
|
+
|
|
71
|
+
- Conda-style revision history showing added, removed, and updated packages
|
|
72
|
+
- Git-based storage with automatic commits and tags
|
|
73
|
+
- Rollback to any previous revision
|
|
74
|
+
- Detailed changelog tracking both dependencies and pinned versions
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Commands
|
|
79
|
+
|
|
80
|
+
| Command | Description |
|
|
81
|
+
|--------------------|-------------|
|
|
82
|
+
| `uvrev env create` | Create a new UV project with revision tracking |
|
|
83
|
+
| `uvrev env list` | List all UV projects |
|
|
84
|
+
| `uvrev add` | Add packages to the current project |
|
|
85
|
+
| `uvrev remove` | Remove packages from the current project |
|
|
86
|
+
| `uvrev sync` | Sync project dependencies |
|
|
87
|
+
| `uvrev list` | List installed packages in the current environment |
|
|
88
|
+
| `uvrev history` | Show revision history with package changes |
|
|
89
|
+
| `uvrev restore` | Restore project to a specific revision |
|
|
90
|
+
| `uvrev path` | Show the path to the current project |
|
|
91
|
+
| `uvrev run` | Run uv commands in the project |
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Environment Selection
|
|
96
|
+
|
|
97
|
+
`uvrev` determines the active environment based on the **currently activated virtual environment**.
|
|
98
|
+
|
|
99
|
+
Activate an environment as usual:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
source .venv/bin/activate
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Once activated, all `uvrev` commands operate on that environment automatically. No additional flags or environment variables are required.
|
|
106
|
+
|
|
107
|
+
This mirrors standard Python workflows and avoids implicit global state.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Examples
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
uvrev env create myproject --python 3.12
|
|
115
|
+
|
|
116
|
+
# activate venv
|
|
117
|
+
source "$(uvrev path -e myproject)/.venv/bin/activate"
|
|
118
|
+
|
|
119
|
+
uvrev add pandas numpy
|
|
120
|
+
uvrev add requests
|
|
121
|
+
|
|
122
|
+
uvrev list
|
|
123
|
+
uvrev history
|
|
124
|
+
uvrev restore 3
|
|
125
|
+
uvrev run pip list
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Why uvrev?
|
|
131
|
+
|
|
132
|
+
`uvrev` combines the speed and simplicity of **uv** with the safety and transparency of **Git**, giving you:
|
|
133
|
+
|
|
134
|
+
- Reproducible environments
|
|
135
|
+
- Clear insight into dependency changes
|
|
136
|
+
- Confidence to experiment, knowing you can always roll back
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
uvrev/__init__.py,sha256=qiMYnTiZppLhNj7ScuS5pZ-1dx3xVjYYGresSj-Jw-0,93
|
|
2
|
+
uvrev/cli.py,sha256=ORUP9qhlEAb0J-5VsWH1cGipkyWJBE0RFl68ZsU0Hxc,6139
|
|
3
|
+
uvrev/commands.py,sha256=c-8DP_q5jYPvgqQkOhd4Onw2Y68fcmjmtwr_lzf_9f0,2415
|
|
4
|
+
uvrev/constants.py,sha256=cWRiCWPrJzG0ChmthPAkMH5HnEYJOoPfQNxoSkxcpsA,163
|
|
5
|
+
uvrev/differ.py,sha256=lV7TT5pr3n4RYzg8oQ23KM_BmJUw1HUWEMfKiNnLniU,4225
|
|
6
|
+
uvrev/exceptions.py,sha256=acHXQNguXD69z7hm9hGZ7AvkTEOH8ego-V6N6XIqpuY,44
|
|
7
|
+
uvrev/io.py,sha256=HFWDfG8AWjf9MXWCrqlYzmriinOfGBXSTQx5NiCRq9k,651
|
|
8
|
+
uvrev/operations.py,sha256=IAMOWgt0gNFlX-Q20cpH66TPjRqWXXy5oYtqpunHx94,7799
|
|
9
|
+
uvrev/parser.py,sha256=08F4X3XrQ5oacjAmc6i598tvgaK--zovv--5LOUopIA,2670
|
|
10
|
+
uvrev/terminal.py,sha256=fSK-vXWvjuqII3c8vPgv924-VZumvXQ54JZnmNKHM5A,408
|
|
11
|
+
uvrev/types.py,sha256=VBpMyrGowmzpl06e0ou-ocjZ3ebN2eVwnT6SC4WC-hU,1727
|
|
12
|
+
uvrev-0.1.dist-info/METADATA,sha256=ZApYH5owYiyC4gOdXdHiDiKPp0INXa-y5vYiso3Y-20,4054
|
|
13
|
+
uvrev-0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
uvrev-0.1.dist-info/entry_points.txt,sha256=G1YjqyIGzUsBZ8FY4Qg8Ay6nOlgbkdxD8m7Uyfan3GE,40
|
|
15
|
+
uvrev-0.1.dist-info/RECORD,,
|