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 ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ uvrev - UV package manager with conda-style revision tracking
4
+ """
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
@@ -0,0 +1,5 @@
1
+ from typing import Final
2
+
3
+ DEFAULT_PYTHON: Final[str] = "3.12"
4
+ DEFAULT_ENV: Final[str] = "base"
5
+ INITIAL_COMMIT_MSG: Final[str] = "(rev 0) Initial project creation"
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ uvrev = uvrev:cli.cli