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 +1 -0
- uvault/add.py +108 -0
- uvault/cli.py +87 -0
- uvault/develop.py +124 -0
- uvault/sync.py +228 -0
- uvault/vcs.py +281 -0
- uvault-0.2.0.dist-info/METADATA +23 -0
- uvault-0.2.0.dist-info/RECORD +11 -0
- uvault-0.2.0.dist-info/WHEEL +4 -0
- uvault-0.2.0.dist-info/entry_points.txt +2 -0
- uvault-0.2.0.dist-info/licenses/LICENSE +21 -0
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,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.
|