uvault 0.2.0__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.
uvault/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
uvault/add.py ADDED
@@ -0,0 +1,108 @@
1
+ from pathlib import Path
2
+ import urllib.parse
3
+ import tomlkit
4
+
5
+ from uvault.vcs import guess_repository_url
6
+
7
+
8
+ class AddCommand:
9
+ def __init__(
10
+ self,
11
+ package: str,
12
+ url: str | None = None,
13
+ pr: str | None = None,
14
+ branch: str | None = None,
15
+ tag: str | None = None,
16
+ rev: str | None = None,
17
+ subdirectory: str | None = None,
18
+ pyproject_path: str = "pyproject.toml",
19
+ ):
20
+ self.package = package
21
+ self.url = url
22
+ self.pr = pr
23
+ self.branch = branch
24
+ self.tag = tag
25
+ self.rev = rev
26
+ self.subdirectory = subdirectory
27
+ self.pyproject_path = Path(pyproject_path)
28
+
29
+ def _parse_url(self):
30
+ if not self.url:
31
+ return
32
+
33
+ # Handle git+https://... format
34
+ if self.url.startswith("git+"):
35
+ url_no_git = self.url[4:]
36
+ parsed = urllib.parse.urlparse(url_no_git)
37
+
38
+ # Extract rev/branch/tag from @...
39
+ if "@" in parsed.path:
40
+ path, ref = parsed.path.split("@", 1)
41
+ self.url = f"{parsed.scheme}://{parsed.netloc}{path}"
42
+ self.rev = (
43
+ ref # by default it's a rev, could be branch/tag but uv accepts rev
44
+ )
45
+ else:
46
+ self.url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
47
+
48
+ # Extract subdirectory from #subdirectory=...
49
+ if parsed.fragment:
50
+ fragments = urllib.parse.parse_qs(parsed.fragment)
51
+ if "subdirectory" in fragments:
52
+ self.subdirectory = fragments["subdirectory"][0]
53
+
54
+ def run(self):
55
+ if not self.pyproject_path.exists():
56
+ print("pyproject.toml not found")
57
+ return 1
58
+
59
+ self._parse_url()
60
+
61
+ if not self.url:
62
+ guessed_url = guess_repository_url(self.package)
63
+ if not guessed_url:
64
+ print(f"Could not find or guess repository URL for {self.package}")
65
+ return 1
66
+ self.url = guessed_url
67
+ print(f"Guessed repository URL: {self.url}")
68
+
69
+ with open(self.pyproject_path, "r", encoding="utf-8") as f:
70
+ doc = tomlkit.parse(f.read())
71
+
72
+ if "tool" not in doc:
73
+ doc.add("tool", tomlkit.table())
74
+ if "uvault" not in doc["tool"]:
75
+ doc["tool"].add("uvault", tomlkit.table())
76
+ if "sources" not in doc["tool"]["uvault"]:
77
+ doc["tool"]["uvault"].add("sources", tomlkit.table())
78
+
79
+ uvault_sources = doc["tool"]["uvault"]["sources"]
80
+
81
+ new_source = tomlkit.inline_table()
82
+ new_source["git"] = self.url
83
+
84
+ # Priority: pr, rev, branch, tag
85
+ if self.pr:
86
+ if "github.com" in self.url:
87
+ new_source["rev"] = f"refs/pull/{self.pr}/head"
88
+ else:
89
+ new_source["rev"] = f"refs/merge-requests/{self.pr}/head"
90
+ elif self.rev:
91
+ new_source["rev"] = self.rev
92
+ elif self.branch:
93
+ new_source["branch"] = self.branch
94
+ elif self.tag:
95
+ new_source["tag"] = self.tag
96
+
97
+ if self.subdirectory:
98
+ new_source["subdirectory"] = self.subdirectory
99
+
100
+ uvault_sources[self.package] = new_source
101
+
102
+ with open(self.pyproject_path, "w", encoding="utf-8") as f:
103
+ f.write(tomlkit.dumps(doc))
104
+
105
+ print(f"Added {self.package} to [tool.uvault.sources]")
106
+ print("Run `uvault sync` to lock this dependency in [tool.uv.sources]")
107
+
108
+ return 0
uvault/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ import argparse
2
+ import sys
3
+ from uvault.sync import SyncCommand
4
+ from uvault.develop import DevelopCommand
5
+ from uvault.add import AddCommand
6
+
7
+
8
+ def main(argv=None):
9
+ if argv is None:
10
+ argv = sys.argv[1:] # pragma: no cover
11
+
12
+ parser = argparse.ArgumentParser(
13
+ description="Development and vaulting workflow for uv VCS dependencies."
14
+ )
15
+ subparsers = parser.add_subparsers(dest="command", required=True)
16
+
17
+ # Sync command
18
+ sync_parser = subparsers.add_parser("sync", help="Sync and vault VCS dependencies.")
19
+ sync_parser.add_argument(
20
+ "-U",
21
+ "--update",
22
+ action="store_true",
23
+ help="Force update the vaulted reference.",
24
+ )
25
+ sync_parser.add_argument(
26
+ "-P",
27
+ "--package",
28
+ action="append",
29
+ help="Specific package to sync. Can be used multiple times.",
30
+ )
31
+
32
+ sync_parser.add_argument(
33
+ "--delete-extra",
34
+ action="store_true",
35
+ help="Delete references in uv.sources that are not in uvault.sources.",
36
+ )
37
+ sync_parser.add_argument(
38
+ "--keep-develop",
39
+ action="store_true",
40
+ help="Keep packages that are in local develop mode instead of restoring them.",
41
+ )
42
+
43
+ # Add command
44
+ add_parser = subparsers.add_parser("add", help="Add a new vaulting intention.")
45
+ add_parser.add_argument("package", help="The package to add")
46
+ add_parser.add_argument("url", nargs="?", help="The VCS URL or PEP 508 string")
47
+ add_parser.add_argument("--pr", help="Pull request number")
48
+ add_parser.add_argument("--branch", help="Branch name")
49
+ add_parser.add_argument("--tag", help="Tag name")
50
+ add_parser.add_argument("--rev", help="Revision SHA")
51
+ add_parser.add_argument("--subdirectory", help="Subdirectory in the repository")
52
+
53
+ # Develop command
54
+ dev_parser = subparsers.add_parser("develop", help="Develop a package locally.")
55
+ dev_parser.add_argument("package", help="The package to develop")
56
+ dev_parser.add_argument("branch", help="Branch name to checkout or create")
57
+
58
+ args = parser.parse_args(argv)
59
+
60
+ if args.command == "sync":
61
+ cmd = SyncCommand(
62
+ packages=args.package,
63
+ update=args.update,
64
+ delete_extra=args.delete_extra,
65
+ keep_develop=args.keep_develop,
66
+ )
67
+ return cmd.run()
68
+ elif args.command == "develop":
69
+ cmd = DevelopCommand(package=args.package, branch=args.branch)
70
+ return cmd.run()
71
+ elif args.command == "add":
72
+ cmd = AddCommand(
73
+ package=args.package,
74
+ url=args.url,
75
+ pr=args.pr,
76
+ branch=args.branch,
77
+ tag=args.tag,
78
+ rev=args.rev,
79
+ subdirectory=args.subdirectory,
80
+ )
81
+ return cmd.run()
82
+
83
+ return 0 # pragma: no cover
84
+
85
+
86
+ if __name__ == "__main__": # pragma: no cover
87
+ sys.exit(main())
uvault/develop.py ADDED
@@ -0,0 +1,124 @@
1
+ from pathlib import Path
2
+ import tomlkit
3
+
4
+ from uvault.vcs import GitVcs, GitReference, get_repo_name, compute_vault_urls
5
+
6
+
7
+ class DevelopCommand:
8
+ def __init__(
9
+ self,
10
+ package: str,
11
+ branch: str,
12
+ pyproject_path: str = "pyproject.toml",
13
+ ):
14
+ self.package = package
15
+ self.branch = branch
16
+ self.pyproject_path = Path(pyproject_path)
17
+ self.vcs = GitVcs()
18
+
19
+ def _read_user_config(self) -> dict:
20
+ config_path = Path("~/.config/uvault/config.toml").expanduser()
21
+ if config_path.exists():
22
+ with open(config_path, "r", encoding="utf-8") as f:
23
+ try:
24
+ return tomlkit.parse(f.read())
25
+ except Exception:
26
+ pass
27
+ return {}
28
+
29
+ def run(self):
30
+ if not self.pyproject_path.exists():
31
+ print("pyproject.toml not found")
32
+ return 1
33
+
34
+ with open(self.pyproject_path, "r", encoding="utf-8") as f:
35
+ doc = tomlkit.parse(f.read())
36
+
37
+ tool_uvault = doc.get("tool", {}).get("uvault", {})
38
+ sources = tool_uvault.get("sources", {})
39
+
40
+ origin_url = None
41
+ git_ref = None
42
+ subdirectory = None
43
+
44
+ if self.package in sources and "git" in sources[self.package]:
45
+ source_cfg = sources[self.package]
46
+ origin_url = source_cfg["git"]
47
+ git_ref = GitReference.from_config(source_cfg)
48
+ subdirectory = source_cfg.get("subdirectory")
49
+ else:
50
+ print(
51
+ f"Could not find configuration for {self.package} in [tool.uvault.sources]."
52
+ )
53
+ print("Please run `uvault add` first to declare it.")
54
+ return 1
55
+
56
+ dev_directory = tool_uvault.get("dev_directory", ".src")
57
+ dest_dir = self.pyproject_path.parent / dev_directory / self.package
58
+
59
+ repo_name = get_repo_name(origin_url)
60
+
61
+ vaults = tool_uvault.get("vcs_vaults", [])
62
+ if vaults:
63
+ vault_config = next((v for v in vaults if v.get("default")), vaults[0])
64
+ _, vault_push_url = compute_vault_urls(repo_name, vault_config)
65
+ else:
66
+ vault_push_url = None
67
+
68
+ if dest_dir.exists():
69
+ print(f"Directory {dest_dir} already exists. Checking status...")
70
+ # Check if clean
71
+ if not self.vcs.check_clean_state(dest_dir):
72
+ print(f"Error: {dest_dir} has uncommitted changes. Aborting.")
73
+ return 1
74
+ else:
75
+ print(f"Cloning {origin_url} into {dest_dir}...")
76
+ dest_dir.parent.mkdir(parents=True, exist_ok=True)
77
+ self.vcs.clone(origin_url, dest_dir)
78
+
79
+ # Configure remotes
80
+ self.vcs.set_remote(dest_dir, "origin", origin_url)
81
+
82
+ if vault_push_url:
83
+ self.vcs.set_remote(dest_dir, "vault", vault_push_url)
84
+
85
+ user_config = self._read_user_config()
86
+ remotes = user_config.get("remotes", {})
87
+ for remote_name, remote_prefix in remotes.items():
88
+ if remote_name in ("origin", "vault"):
89
+ continue
90
+ remote_url = f"{remote_prefix.rstrip('/')}/{repo_name}.git"
91
+ # just add the remote without fetching
92
+ self.vcs.set_remote(dest_dir, remote_name, remote_url)
93
+
94
+ # Checkout requested branch or ref
95
+ if not self.vcs.checkout_reference(dest_dir, origin_url, git_ref, self.branch):
96
+ print(
97
+ f"Could not resolve reference {git_ref.value if git_ref else 'None'} at {origin_url}"
98
+ )
99
+ return 1
100
+
101
+ if "uv" not in doc["tool"]:
102
+ doc["tool"].add("uv", tomlkit.table())
103
+ if "sources" not in doc["tool"]["uv"]:
104
+ doc["tool"]["uv"].add("sources", tomlkit.table())
105
+
106
+ uv_sources = doc["tool"]["uv"]["sources"]
107
+
108
+ new_source = tomlkit.inline_table()
109
+ # Compute relative path
110
+ rel_path = f"./{dev_directory.rstrip('/')}/{self.package}"
111
+ if subdirectory:
112
+ rel_path = f"{rel_path}/{subdirectory}"
113
+ new_source["path"] = rel_path
114
+ new_source["editable"] = True
115
+
116
+ uv_sources[self.package] = new_source
117
+
118
+ with open(self.pyproject_path, "w", encoding="utf-8") as f:
119
+ f.write(tomlkit.dumps(doc))
120
+
121
+ print(f"Updated pyproject.toml to use local editable path for {self.package}")
122
+ print("Please run `uv sync` or `uv lock` to update your uv.lock file.")
123
+
124
+ return 0
uvault/sync.py ADDED
@@ -0,0 +1,228 @@
1
+ from pathlib import Path
2
+ import tomlkit
3
+ import re
4
+ from uvault.vcs import (
5
+ GitVcs,
6
+ VcsProvider,
7
+ GitReference,
8
+ get_repo_name,
9
+ compute_vault_urls,
10
+ )
11
+
12
+
13
+ def normalize_pkg_name(name: str) -> str:
14
+ """Normalize a Python package name for comparison."""
15
+ return re.sub(r"[-_.]+", "-", name).lower()
16
+
17
+
18
+ class PackageSyncer:
19
+ def __init__(
20
+ self,
21
+ pkg: str,
22
+ source_cfg: dict,
23
+ vcs: VcsProvider,
24
+ cache_dir: Path,
25
+ vault_config: dict,
26
+ tag_prefix: str,
27
+ force_update: bool,
28
+ project_version: str | None = None,
29
+ include_project_version: bool = True,
30
+ ):
31
+ self.pkg = pkg
32
+ self.source_cfg = source_cfg
33
+ self.vcs = vcs
34
+ self.cache_dir = cache_dir
35
+ self.vault_config = vault_config
36
+ self.tag_prefix = tag_prefix
37
+ self.force_update = force_update
38
+ self.project_version = project_version
39
+ self.include_project_version = include_project_version
40
+
41
+ def process(self) -> dict | None:
42
+ origin_git = self.source_cfg.get("git")
43
+ git_ref = GitReference.from_config(self.source_cfg)
44
+
45
+ if not origin_git or not git_ref:
46
+ print(
47
+ f"Package {self.pkg} is missing 'git' or a valid reference ('rev', 'tag', 'branch') in [tool.uvault.sources]"
48
+ )
49
+ return None
50
+
51
+ print(f"Syncing {self.pkg}...")
52
+
53
+ sha = self.vcs.get_remote_sha(origin_git, git_ref)
54
+ if not sha:
55
+ print(f"Failed to resolve {git_ref.value} in {origin_git}")
56
+ return None
57
+
58
+ tag_name = self.tag_prefix
59
+ if self.include_project_version and self.project_version:
60
+ tag_name += f"{self.project_version}+"
61
+ tag_name += sha
62
+ repo_name = get_repo_name(origin_git)
63
+ vault_fetch_url, vault_push_url = compute_vault_urls(
64
+ repo_name, self.vault_config
65
+ )
66
+
67
+ if not self.force_update and self.vcs.remote_tag_exists(
68
+ vault_push_url, tag_name
69
+ ):
70
+ print(
71
+ f"Tag {tag_name} already exists in vault {vault_push_url}. Skipping vaulting."
72
+ )
73
+ else:
74
+ print(f"Vaulting to {vault_push_url} with tag {tag_name}...")
75
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
76
+ repo_dir = self.cache_dir / repo_name
77
+ self.vcs.vault_reference(
78
+ origin_git, sha, vault_push_url, tag_name, repo_dir
79
+ )
80
+
81
+ new_source = tomlkit.inline_table()
82
+ new_source["git"] = vault_fetch_url
83
+ new_source["tag"] = tag_name
84
+ if "subdirectory" in self.source_cfg:
85
+ new_source["subdirectory"] = self.source_cfg["subdirectory"]
86
+
87
+ return new_source
88
+
89
+
90
+ class SyncCommand:
91
+ def __init__(
92
+ self,
93
+ packages: str | list[str] | None = None,
94
+ update: bool = False,
95
+ delete_extra: bool = False,
96
+ keep_develop: bool = False,
97
+ pyproject_path: str = "pyproject.toml",
98
+ cache_dir: str = "~/.cache/uvault",
99
+ vcs: VcsProvider | None = None,
100
+ ):
101
+ if isinstance(packages, str):
102
+ self.packages = [packages]
103
+ else:
104
+ self.packages = packages or []
105
+ self.update = update
106
+ self.delete_extra = delete_extra
107
+ self.keep_develop = keep_develop
108
+ self.pyproject_path = Path(pyproject_path)
109
+ self.cache_dir = Path(cache_dir).expanduser()
110
+ self.vcs = vcs or GitVcs()
111
+
112
+ def run(self):
113
+ if not self.pyproject_path.exists():
114
+ print("pyproject.toml not found")
115
+ return 1
116
+
117
+ with open(self.pyproject_path, "r", encoding="utf-8") as f:
118
+ doc = tomlkit.parse(f.read())
119
+
120
+ tool_uvault = doc.get("tool", {}).get("uvault", {})
121
+ if not tool_uvault:
122
+ print("No [tool.uvault] section in pyproject.toml")
123
+ return 1
124
+
125
+ tag_prefix = tool_uvault.get("tag_prefix", "")
126
+ vaults = tool_uvault.get("vcs_vaults", [])
127
+ if not vaults:
128
+ print("No [[tool.uvault.vcs_vaults]] configured.")
129
+ return 1
130
+
131
+ vault_config = next((v for v in vaults if v.get("default")), vaults[0])
132
+
133
+ project_version = doc.get("project", {}).get("version")
134
+ include_project_version = tool_uvault.get("include_project_version", True)
135
+
136
+ sources = tool_uvault.get("sources", {})
137
+
138
+ if "uv" not in doc["tool"]:
139
+ doc["tool"].add("uv", tomlkit.table())
140
+
141
+ if "sources" not in doc["tool"]["uv"]:
142
+ doc["tool"]["uv"].add("sources", tomlkit.table())
143
+
144
+ uv_sources = doc["tool"]["uv"]["sources"]
145
+
146
+ packages_to_sync = self.packages if self.packages else list(sources.keys())
147
+
148
+ has_changes = False
149
+
150
+ normalized_sources = {normalize_pkg_name(k): k for k in sources.keys()}
151
+ normalized_uv_sources = {normalize_pkg_name(k): k for k in uv_sources.keys()}
152
+ normalized_packages = {normalize_pkg_name(p) for p in self.packages}
153
+
154
+ for pkg in packages_to_sync:
155
+ norm_pkg = normalize_pkg_name(pkg)
156
+ if norm_pkg not in normalized_sources:
157
+ print(f"Package {pkg} not found in [tool.uvault.sources]")
158
+ continue
159
+
160
+ actual_source_key = normalized_sources[norm_pkg]
161
+ force_update = self.update or norm_pkg in normalized_packages
162
+
163
+ is_develop = False
164
+ if norm_pkg in normalized_uv_sources:
165
+ uv_pkg_key = normalized_uv_sources[norm_pkg]
166
+ uv_pkg_cfg = uv_sources.get(uv_pkg_key)
167
+ if isinstance(uv_pkg_cfg, dict):
168
+ is_develop = (
169
+ uv_pkg_cfg.get("editable") is True or "path" in uv_pkg_cfg
170
+ )
171
+
172
+ if is_develop:
173
+ if self.keep_develop:
174
+ print(
175
+ f"Package {pkg} is in develop mode. Skipping (--keep-develop is set)."
176
+ )
177
+ continue
178
+ else:
179
+ force_update = True
180
+ print(
181
+ f"Package {pkg} is in develop mode. Restoring to vaulted state."
182
+ )
183
+
184
+ if norm_pkg in normalized_uv_sources and not force_update:
185
+ print(
186
+ f"Package {pkg} is already in [tool.uv.sources]. Skipping (use --update to force)."
187
+ )
188
+ continue
189
+
190
+ syncer = PackageSyncer(
191
+ pkg=actual_source_key,
192
+ source_cfg=sources[actual_source_key],
193
+ vcs=self.vcs,
194
+ cache_dir=self.cache_dir,
195
+ vault_config=vault_config,
196
+ tag_prefix=tag_prefix,
197
+ force_update=force_update,
198
+ project_version=project_version,
199
+ include_project_version=include_project_version,
200
+ )
201
+
202
+ new_source = syncer.process()
203
+ if new_source is not None:
204
+ # Remove the old unnormalized key from uv_sources if it exists
205
+ if (
206
+ norm_pkg in normalized_uv_sources
207
+ and normalized_uv_sources[norm_pkg] != actual_source_key
208
+ ):
209
+ del uv_sources[normalized_uv_sources[norm_pkg]]
210
+
211
+ uv_sources[actual_source_key] = new_source
212
+ normalized_uv_sources[norm_pkg] = actual_source_key
213
+ has_changes = True
214
+
215
+ if self.delete_extra:
216
+ for uv_pkg in list(uv_sources.keys()):
217
+ if normalize_pkg_name(uv_pkg) not in normalized_sources:
218
+ del uv_sources[uv_pkg]
219
+ print(f"Removed extra package {uv_pkg} from [tool.uv.sources].")
220
+ has_changes = True
221
+
222
+ if has_changes:
223
+ with open(self.pyproject_path, "w", encoding="utf-8") as f:
224
+ f.write(tomlkit.dumps(doc))
225
+ print("Updated pyproject.toml")
226
+ print("Please run `uv sync` or `uv lock` to update your uv.lock file.")
227
+
228
+ return 0
uvault/vcs.py ADDED
@@ -0,0 +1,281 @@
1
+ import abc
2
+ import subprocess
3
+ import re
4
+ from pathlib import Path
5
+ from dataclasses import dataclass
6
+ from urllib.parse import urlparse
7
+ from importlib.metadata import metadata, PackageNotFoundError
8
+
9
+
10
+ def guess_repository_url(package_name: str) -> str | None:
11
+ try:
12
+ meta = metadata(package_name)
13
+ except PackageNotFoundError:
14
+ return None
15
+
16
+ urls = []
17
+ homepage = meta.get("Home-page")
18
+ if homepage:
19
+ urls.append(("Home-page", homepage))
20
+
21
+ project_urls = meta.get_all("Project-URL") or []
22
+ for purl in project_urls:
23
+ if "," in purl:
24
+ name, url = purl.split(",", 1)
25
+ urls.append((name.strip(), url.strip()))
26
+
27
+ best_score = -1
28
+ best_url = None
29
+
30
+ for name, url in urls:
31
+ score = 0
32
+ name_lower = name.lower()
33
+ if "source" in name_lower or "repository" in name_lower:
34
+ score += 20
35
+ elif "home" in name_lower:
36
+ score += 10
37
+
38
+ url_lower = url.lower()
39
+ if "github.com" in url_lower:
40
+ score += 5
41
+ elif "gitlab.com" in url_lower:
42
+ score += 5
43
+ elif "git" in url_lower:
44
+ score += 2
45
+
46
+ if score > best_score:
47
+ best_score = score
48
+ best_url = url
49
+
50
+ return best_url
51
+
52
+
53
+ def get_repo_name(git_url: str) -> str:
54
+ if git_url.startswith("git@"):
55
+ path = git_url.split(":")[-1]
56
+ elif git_url.startswith("ssh://"):
57
+ parsed = urlparse(git_url)
58
+ path = parsed.path
59
+ else:
60
+ parsed = urlparse(git_url)
61
+ path = parsed.path
62
+
63
+ repo_name = path.split("/")[-1]
64
+ if repo_name.endswith(".git"):
65
+ repo_name = repo_name[:-4]
66
+ return repo_name
67
+
68
+
69
+ def compute_vault_urls(repo_name: str, vault_config: dict) -> tuple[str, str]:
70
+ provider = vault_config.get("provider", "github.com")
71
+ owner = vault_config.get("owner", "")
72
+ fetch_ssh = vault_config.get("fetch_ssh", False)
73
+ push_ssh = vault_config.get("push_ssh", True)
74
+
75
+ path = f"{owner}/{repo_name}.git" if owner else f"{repo_name}.git"
76
+
77
+ fetch_url = (
78
+ f"ssh://git@{provider}/{path}" if fetch_ssh else f"https://{provider}/{path}"
79
+ )
80
+ push_url = (
81
+ f"ssh://git@{provider}/{path}" if push_ssh else f"https://{provider}/{path}"
82
+ )
83
+
84
+ return fetch_url, push_url
85
+
86
+
87
+ @dataclass
88
+ class GitReference:
89
+ ref_type: str
90
+ value: str
91
+
92
+ @classmethod
93
+ def from_config(cls, source_cfg: dict) -> "GitReference | None":
94
+ for key in ["rev", "tag", "branch"]:
95
+ if key in source_cfg:
96
+ return cls(key, source_cfg[key])
97
+ return None
98
+
99
+ def get_ls_remote_args(self) -> list[str]:
100
+ if self.ref_type == "tag":
101
+ return ["--tags", self.value]
102
+ elif self.ref_type == "branch":
103
+ return ["--heads", self.value]
104
+ else:
105
+ return [self.value]
106
+
107
+
108
+ class VcsProvider(abc.ABC):
109
+ @abc.abstractmethod
110
+ def get_remote_sha(self, origin_url: str, ref: GitReference) -> str | None:
111
+ pass # pragma: no cover
112
+
113
+ @abc.abstractmethod
114
+ def remote_tag_exists(self, vault_url: str, tag_name: str) -> bool:
115
+ pass # pragma: no cover
116
+
117
+ @abc.abstractmethod
118
+ def vault_reference(
119
+ self, origin_url: str, sha: str, vault_url: str, tag_name: str, repo_dir: Path
120
+ ) -> None:
121
+ pass # pragma: no cover
122
+
123
+ @abc.abstractmethod
124
+ def check_clean_state(self, repo_dir: Path) -> bool:
125
+ pass # pragma: no cover
126
+
127
+ @abc.abstractmethod
128
+ def fetch_remote(
129
+ self, repo_dir: Path, remote: str = "origin", ref: str | None = None
130
+ ) -> None:
131
+ pass # pragma: no cover
132
+
133
+ @abc.abstractmethod
134
+ def checkout_reference(
135
+ self, repo_dir: Path, origin_url: str, ref: GitReference | None, branch: str
136
+ ) -> bool:
137
+ pass # pragma: no cover
138
+
139
+ @abc.abstractmethod
140
+ def clone(self, url: str, dest: Path) -> None:
141
+ pass # pragma: no cover
142
+
143
+ @abc.abstractmethod
144
+ def set_remote(self, repo_dir: Path, name: str, url: str) -> None:
145
+ pass # pragma: no cover
146
+
147
+
148
+ class GitVcs(VcsProvider):
149
+ def get_remote_sha(self, origin_url: str, ref: GitReference) -> str | None:
150
+ args = ["git", "ls-remote", origin_url] + ref.get_ls_remote_args()
151
+ try:
152
+ result = subprocess.run(
153
+ args,
154
+ capture_output=True,
155
+ text=True,
156
+ check=True,
157
+ )
158
+ output = result.stdout.strip()
159
+ if output:
160
+ return output.split()[0]
161
+ except subprocess.CalledProcessError:
162
+ pass
163
+
164
+ if ref.ref_type == "rev" and re.match(r"^[0-9a-f]{40}$", ref.value):
165
+ return ref.value
166
+ return None
167
+
168
+ def remote_tag_exists(self, vault_url: str, tag_name: str) -> bool:
169
+ try:
170
+ result = subprocess.run(
171
+ ["git", "ls-remote", "--tags", vault_url, tag_name],
172
+ capture_output=True,
173
+ text=True,
174
+ check=True,
175
+ )
176
+ output = result.stdout.strip()
177
+ if output:
178
+ return True
179
+ except subprocess.CalledProcessError:
180
+ pass
181
+ return False
182
+
183
+ def fetch_commit_from_reference(
184
+ self, repo_dir: Path, origin_url: str, ref: GitReference | str
185
+ ) -> str | None:
186
+ if isinstance(ref, GitReference):
187
+ sha = self.get_remote_sha(origin_url, ref)
188
+ else:
189
+ sha = ref
190
+
191
+ if not sha:
192
+ return None
193
+
194
+ print(f"fetching {sha} in {repo_dir}")
195
+ self.fetch_remote(repo_dir, "origin", sha)
196
+ return sha
197
+
198
+ def vault_reference(
199
+ self, origin_url: str, sha: str, vault_url: str, tag_name: str, repo_dir: Path
200
+ ) -> None:
201
+ if not repo_dir.exists():
202
+ subprocess.run(
203
+ ["git", "clone", "--bare", origin_url, str(repo_dir)], check=True
204
+ )
205
+ self.fetch_commit_from_reference(repo_dir, origin_url, sha)
206
+ subprocess.run(
207
+ [
208
+ "git",
209
+ "-C",
210
+ str(repo_dir),
211
+ "push",
212
+ vault_url,
213
+ f"{sha}:refs/tags/{tag_name}",
214
+ ],
215
+ check=True,
216
+ )
217
+
218
+ def check_clean_state(self, repo_dir: Path) -> bool:
219
+ res = subprocess.run(
220
+ ["git", "-C", str(repo_dir), "status", "--porcelain"],
221
+ capture_output=True,
222
+ text=True,
223
+ )
224
+ return not bool(res.stdout.strip())
225
+
226
+ def fetch_remote(
227
+ self, repo_dir: Path, remote: str = "origin", ref: str | None = None
228
+ ) -> None:
229
+ args = ["git", "-C", str(repo_dir), "fetch", remote]
230
+ if ref:
231
+ args.append(ref)
232
+ subprocess.run(args, check=True)
233
+
234
+ def checkout_reference(
235
+ self, repo_dir: Path, origin_url: str, ref: GitReference | None, branch: str
236
+ ) -> bool:
237
+ if not ref:
238
+ print("Error: No VCS reference provided.")
239
+ return False
240
+
241
+ sha = self.fetch_commit_from_reference(repo_dir, origin_url, ref)
242
+ if not sha:
243
+ return False
244
+
245
+ res = subprocess.run(
246
+ [
247
+ "git",
248
+ "-C",
249
+ str(repo_dir),
250
+ "show-ref",
251
+ "--verify",
252
+ "--quiet",
253
+ f"refs/heads/{branch}",
254
+ ]
255
+ )
256
+ if res.returncode == 0:
257
+ print(f"Switching to existing branch '{branch}'")
258
+ subprocess.run(["git", "-C", str(repo_dir), "checkout", branch], check=True)
259
+ else:
260
+ print(f"Creating and switching to new branch '{branch}' from {sha}")
261
+ subprocess.run(
262
+ ["git", "-C", str(repo_dir), "checkout", "-b", branch, sha], check=True
263
+ )
264
+ return True
265
+
266
+ def clone(self, url: str, dest: Path) -> None:
267
+ subprocess.run(
268
+ ["git", "clone", "--filter=blob:none", url, str(dest)], check=True
269
+ )
270
+
271
+ def set_remote(self, repo_dir: Path, name: str, url: str) -> None:
272
+ subprocess.run(
273
+ ["git", "-C", str(repo_dir), "remote", "add", name, url],
274
+ check=False,
275
+ capture_output=True,
276
+ )
277
+ subprocess.run(
278
+ ["git", "-C", str(repo_dir), "remote", "set-url", name, url],
279
+ check=False,
280
+ capture_output=True,
281
+ )
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: uvault
3
+ Version: 0.2.0
4
+ Summary: Development and vaulting workflow for uv VCS dependencies.
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: diskcache>=5.6.3
8
+ Requires-Dist: tomlkit>=0.15.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # uvault
12
+
13
+ Development and vaulting workflow for [uv](https://github.com/astral-sh/uv) VCS dependencies.
14
+
15
+ Secure your Python projects against deleted or force-pushed upstream commits. `uvault` automatically vaults transient VCS references (like GitHub PRs) into your organization's own repositories, while allowing developers to instantly switch dependencies into local editable mode—fully integrated with `pyproject.toml` and `uv`.
16
+
17
+ ## Documentation
18
+
19
+ The complete documentation is available in the `docs/` folder:
20
+
21
+ * [Quickstart & Key Features](docs/index.md) - Learn what `uvault` is and how to get started quickly.
22
+ * [How-To Guides](docs/how-to.md) - Step-by-step guides for installing and using `uvault` in your day-to-day workflow.
23
+ * [CLI & Configuration Reference](docs/reference.md) - Detailed information on `pyproject.toml` configuration (`[tool.uvault]`) and all CLI commands (`sync`, `add`, `develop`).
@@ -0,0 +1,11 @@
1
+ uvault/__init__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
2
+ uvault/add.py,sha256=D04t4vQetyWyjtShSZy1LKPu59oOpBAq0k9aWXw-UwM,3529
3
+ uvault/cli.py,sha256=-yFaA90lO5E-XG3LCoU9RIP-Vb0XlQ07PhENLKY4Ig8,2862
4
+ uvault/develop.py,sha256=jYE8OGyv8h_c-cJbYDPSbE5EHaDQtvgJywj7qh9dFTs,4447
5
+ uvault/sync.py,sha256=UaHnc2946CjrGyMn_VikGUEg-okfi0FO8S00Iv0M5dA,8066
6
+ uvault/vcs.py,sha256=Tw3GlEZuSxOnenays6Y8Ls44iD5rvF7pp-rCdYTfYuo,8350
7
+ uvault-0.2.0.dist-info/METADATA,sha256=FI--RcIYZLLlNarj5ZDFZSojfxH86zn0_Oi0b4y8bRc,1183
8
+ uvault-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ uvault-0.2.0.dist-info/entry_points.txt,sha256=YoWE87yJANUf16f7uxcCQrzvaEXuXsjmGatNUPrhgNs,43
10
+ uvault-0.2.0.dist-info/licenses/LICENSE,sha256=64_pHIGyduDj8AOXZhRSQc282c68pR4xAHu5Q7JmttQ,1071
11
+ uvault-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ uvault = uvault.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pierre Verkest
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.