pl_vendor 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: pl_vendor
3
+ Version: 0.1.0
4
+ Summary: Vendor Git repositories as ordinary files with a reproducible lockfile
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+
8
+ # pl_vendor
9
+
10
+ `pl_vendor` copies Git repositories into another repository as ordinary files and
11
+ records their exact commits in a deterministic YAML lockfile. It supports optional
12
+ downstream patches and can verify that checked-in vendor trees still match their
13
+ locked upstream commits.
14
+
15
+ ## Requirements
16
+
17
+ - Python 3.13 or newer
18
+ - Git 2.30 or newer
19
+ - uv 0.9 or newer by default
20
+
21
+ The package has no Python runtime dependencies.
22
+
23
+ Every command runs a preflight check before reading or changing vendored trees. Library
24
+ callers that use another environment runner can pass an `ExecutableBackend` to
25
+ `VendorProject.discover()`; that executable and its declared minimum version are checked
26
+ in place of uv. Git is always checked.
27
+
28
+ ## Configuration
29
+
30
+ Create `vendor.toml` at the root of the consuming Git repository:
31
+
32
+ ```toml
33
+ schema_version = 1
34
+
35
+ [dependencies.example]
36
+ path = "vendor/example"
37
+ url = "https://github.com/example/example.git"
38
+ branch = "release"
39
+ ```
40
+
41
+ The generated `vendor-lock.yaml` records the resolved commit:
42
+
43
+ ```yaml
44
+ lockfileVersion: 1
45
+
46
+ dependencies:
47
+ example: '0123456789abcdef0123456789abcdef01234567'
48
+ ```
49
+
50
+ Optional patches live at `.vendor-patches/<dependency-name>.patch` and are applied
51
+ after copying the upstream tree.
52
+
53
+ ## Commands
54
+
55
+ ```sh
56
+ pl-vendor update
57
+ pl-vendor update example
58
+ pl-vendor check
59
+ pl-vendor verify
60
+ ```
61
+
62
+ Each command discovers the consuming repository from the current directory. Pass
63
+ `--root PATH` after the command to operate on a different repository.
64
+
65
+ ## Development
66
+
67
+ ```sh
68
+ uv sync --dev
69
+ uv run pytest
70
+ uv run pyright
71
+ uv run ruff format --check .
72
+ ```
73
+
74
+ The internal test suite is kept in `src/pl_vendor/tests` so the package remains
75
+ self-contained while it is developed or embedded elsewhere.
@@ -0,0 +1,68 @@
1
+ # pl_vendor
2
+
3
+ `pl_vendor` copies Git repositories into another repository as ordinary files and
4
+ records their exact commits in a deterministic YAML lockfile. It supports optional
5
+ downstream patches and can verify that checked-in vendor trees still match their
6
+ locked upstream commits.
7
+
8
+ ## Requirements
9
+
10
+ - Python 3.13 or newer
11
+ - Git 2.30 or newer
12
+ - uv 0.9 or newer by default
13
+
14
+ The package has no Python runtime dependencies.
15
+
16
+ Every command runs a preflight check before reading or changing vendored trees. Library
17
+ callers that use another environment runner can pass an `ExecutableBackend` to
18
+ `VendorProject.discover()`; that executable and its declared minimum version are checked
19
+ in place of uv. Git is always checked.
20
+
21
+ ## Configuration
22
+
23
+ Create `vendor.toml` at the root of the consuming Git repository:
24
+
25
+ ```toml
26
+ schema_version = 1
27
+
28
+ [dependencies.example]
29
+ path = "vendor/example"
30
+ url = "https://github.com/example/example.git"
31
+ branch = "release"
32
+ ```
33
+
34
+ The generated `vendor-lock.yaml` records the resolved commit:
35
+
36
+ ```yaml
37
+ lockfileVersion: 1
38
+
39
+ dependencies:
40
+ example: '0123456789abcdef0123456789abcdef01234567'
41
+ ```
42
+
43
+ Optional patches live at `.vendor-patches/<dependency-name>.patch` and are applied
44
+ after copying the upstream tree.
45
+
46
+ ## Commands
47
+
48
+ ```sh
49
+ pl-vendor update
50
+ pl-vendor update example
51
+ pl-vendor check
52
+ pl-vendor verify
53
+ ```
54
+
55
+ Each command discovers the consuming repository from the current directory. Pass
56
+ `--root PATH` after the command to operate on a different repository.
57
+
58
+ ## Development
59
+
60
+ ```sh
61
+ uv sync --dev
62
+ uv run pytest
63
+ uv run pyright
64
+ uv run ruff format --check .
65
+ ```
66
+
67
+ The internal test suite is kept in `src/pl_vendor/tests` so the package remains
68
+ self-contained while it is developed or embedded elsewhere.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pl_vendor"
7
+ version = "0.1.0"
8
+ description = "Vendor Git repositories as ordinary files with a reproducible lockfile"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ dependencies = []
12
+
13
+ [project.scripts]
14
+ pl-vendor = "pl_vendor.cli:main"
15
+
16
+ [dependency-groups]
17
+ dev = [
18
+ "pyright",
19
+ "pytest",
20
+ "ruff",
21
+ ]
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
25
+
26
+ [tool.pytest.ini_options]
27
+ testpaths = ["src/pl_vendor/tests"]
28
+
29
+ [tool.pyright]
30
+ include = ["src/pl_vendor"]
31
+
32
+ [tool.uv]
33
+ required-version = ">=0.9.0"
34
+
35
+ [tool.ruff]
36
+ target-version = "py313"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,32 @@
1
+ """Vendor Git repositories as ordinary files with a reproducible lockfile."""
2
+
3
+ from .config import (
4
+ VendorConfigError,
5
+ VendorDependency,
6
+ load_vendor_config,
7
+ load_vendor_lock,
8
+ validate_vendor_lock,
9
+ write_vendor_lock,
10
+ )
11
+ from .preflight import (
12
+ MINIMUM_GIT_VERSION,
13
+ MINIMUM_UV_VERSION,
14
+ ExecutableBackend,
15
+ PreflightError,
16
+ )
17
+ from .project import VendorError, VendorProject
18
+
19
+ __all__ = [
20
+ "MINIMUM_GIT_VERSION",
21
+ "MINIMUM_UV_VERSION",
22
+ "ExecutableBackend",
23
+ "PreflightError",
24
+ "VendorConfigError",
25
+ "VendorDependency",
26
+ "VendorError",
27
+ "VendorProject",
28
+ "load_vendor_config",
29
+ "load_vendor_lock",
30
+ "validate_vendor_lock",
31
+ "write_vendor_lock",
32
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,59 @@
1
+ """Command-line interface for pl-vendor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .project import VendorError, VendorProject
10
+
11
+
12
+ def _add_root_argument(parser: argparse.ArgumentParser) -> None:
13
+ parser.add_argument(
14
+ "--root",
15
+ type=Path,
16
+ default=None,
17
+ help="Repository to operate on (defaults to the current Git repository).",
18
+ )
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ parser = argparse.ArgumentParser(
23
+ prog="pl-vendor",
24
+ description="Vendor Git repositories as ordinary, reproducible files.",
25
+ )
26
+ subparsers = parser.add_subparsers(dest="command", required=True)
27
+
28
+ update_parser = subparsers.add_parser(
29
+ "update", help="Update one or all vendored dependencies."
30
+ )
31
+ update_parser.add_argument("dependency", nargs="?", default="all")
32
+ _add_root_argument(update_parser)
33
+
34
+ check_parser = subparsers.add_parser(
35
+ "check", help="Check whether newer upstream revisions exist."
36
+ )
37
+ _add_root_argument(check_parser)
38
+
39
+ verify_parser = subparsers.add_parser(
40
+ "verify", help="Verify vendored files against their locked revisions."
41
+ )
42
+ _add_root_argument(verify_parser)
43
+
44
+ return parser
45
+
46
+
47
+ def main(argv: list[str] | None = None) -> int:
48
+ args = build_parser().parse_args(argv)
49
+ try:
50
+ project = VendorProject.discover(args.root)
51
+ if args.command == "update":
52
+ project.update(args.dependency)
53
+ return 0
54
+ if args.command == "check":
55
+ return 0 if project.check() else 1
56
+ return 0 if project.verify() else 1
57
+ except VendorError as exc:
58
+ print(f"pl-vendor: error: {exc}", file=sys.stderr)
59
+ return 2
@@ -0,0 +1,162 @@
1
+ """Read and write pl-vendor manifests and lockfiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path, PurePosixPath
9
+
10
+ SCHEMA_VERSION = 1
11
+ LOCKFILE_VERSION = 1
12
+ NAME_EXPRESSION = r"[A-Za-z0-9][A-Za-z0-9._-]*"
13
+ REVISION_EXPRESSION = r"[0-9a-f]{40}"
14
+ NAME_PATTERN = re.compile(rf"{NAME_EXPRESSION}\Z")
15
+ REVISION_PATTERN = re.compile(rf"{REVISION_EXPRESSION}\Z")
16
+ LOCK_ENTRY_PATTERN = re.compile(
17
+ rf" (?P<name>{NAME_EXPRESSION}): '(?P<revision>{REVISION_EXPRESSION})'\Z"
18
+ )
19
+
20
+
21
+ class VendorConfigError(ValueError):
22
+ """Raised when vendoring configuration does not satisfy its schema."""
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class VendorDependency:
27
+ name: str
28
+ path: str
29
+ url: str
30
+ branch: str
31
+
32
+
33
+ def load_vendor_config(path: Path) -> dict[str, VendorDependency]:
34
+ try:
35
+ with path.open("rb") as config_file:
36
+ config = tomllib.load(config_file)
37
+ except (OSError, tomllib.TOMLDecodeError) as exc:
38
+ raise VendorConfigError(f"Could not read {path}: {exc}") from exc
39
+
40
+ if config.get("schema_version") != SCHEMA_VERSION:
41
+ raise VendorConfigError(f"{path} must set schema_version = {SCHEMA_VERSION}.")
42
+
43
+ raw_dependencies = config.get("dependencies")
44
+ if not isinstance(raw_dependencies, dict) or not raw_dependencies:
45
+ raise VendorConfigError(f"{path} must declare at least one dependency.")
46
+
47
+ dependencies: dict[str, VendorDependency] = {}
48
+ claimed_paths: set[str] = set()
49
+ expected_fields = {"path", "url", "branch"}
50
+ for name, raw_dependency in raw_dependencies.items():
51
+ if not isinstance(name, str) or NAME_PATTERN.fullmatch(name) is None:
52
+ raise VendorConfigError(f"Invalid dependency name: {name!r}.")
53
+ if not isinstance(raw_dependency, dict):
54
+ raise VendorConfigError(f"Dependency {name!r} must be a TOML table.")
55
+
56
+ missing = expected_fields - raw_dependency.keys()
57
+ unexpected = raw_dependency.keys() - expected_fields
58
+ if missing:
59
+ raise VendorConfigError(
60
+ f"Dependency {name!r} is missing: {', '.join(sorted(missing))}."
61
+ )
62
+ if unexpected:
63
+ raise VendorConfigError(
64
+ f"Dependency {name!r} has unknown fields: "
65
+ f"{', '.join(sorted(unexpected))}."
66
+ )
67
+
68
+ values: dict[str, str] = {}
69
+ for field in ("path", "url", "branch"):
70
+ value = raw_dependency[field]
71
+ if not isinstance(value, str) or not value.strip():
72
+ raise VendorConfigError(
73
+ f"Dependency {name!r} field {field!r} must be a nonempty string."
74
+ )
75
+ values[field] = value
76
+
77
+ dependency_path = PurePosixPath(values["path"])
78
+ if (
79
+ dependency_path.is_absolute()
80
+ or dependency_path == PurePosixPath(".")
81
+ or ".." in dependency_path.parts
82
+ ):
83
+ raise VendorConfigError(
84
+ f"Dependency {name!r} path must stay within the repository."
85
+ )
86
+ if values["path"] in claimed_paths:
87
+ raise VendorConfigError(
88
+ f"More than one dependency uses path {values['path']!r}."
89
+ )
90
+ claimed_paths.add(values["path"])
91
+
92
+ dependencies[name] = VendorDependency(name=name, **values)
93
+
94
+ return dependencies
95
+
96
+
97
+ def load_vendor_lock(path: Path) -> dict[str, str]:
98
+ """Load the deliberately small, generated YAML lockfile format."""
99
+ try:
100
+ lines = path.read_text(encoding="utf-8").splitlines()
101
+ except OSError as exc:
102
+ raise VendorConfigError(f"Could not read {path}: {exc}") from exc
103
+
104
+ header = [f"lockfileVersion: {LOCKFILE_VERSION}", "", "dependencies:"]
105
+ if lines[:3] != header:
106
+ raise VendorConfigError(
107
+ f"{path} must start with lockfileVersion: {LOCKFILE_VERSION} "
108
+ "followed by a dependencies mapping."
109
+ )
110
+
111
+ revisions: dict[str, str] = {}
112
+ for line in lines[3:]:
113
+ match = LOCK_ENTRY_PATTERN.fullmatch(line)
114
+ if match is None:
115
+ raise VendorConfigError(f"Invalid dependency entry in {path}: {line!r}.")
116
+ name = match.group("name")
117
+ if name in revisions:
118
+ raise VendorConfigError(f"Duplicate dependency {name!r} in {path}.")
119
+ revisions[name] = match.group("revision")
120
+
121
+ if not revisions:
122
+ raise VendorConfigError(f"{path} must lock at least one dependency.")
123
+ return revisions
124
+
125
+
126
+ def validate_vendor_lock(
127
+ path: Path, dependencies: dict[str, VendorDependency]
128
+ ) -> dict[str, str]:
129
+ revisions = load_vendor_lock(path)
130
+ expected_names = set(dependencies)
131
+ locked_names = set(revisions)
132
+ missing = expected_names - locked_names
133
+ unexpected = locked_names - expected_names
134
+ if missing:
135
+ raise VendorConfigError(
136
+ f"{path} is missing dependencies: {', '.join(sorted(missing))}."
137
+ )
138
+ if unexpected:
139
+ raise VendorConfigError(
140
+ f"{path} has unknown dependencies: {', '.join(sorted(unexpected))}."
141
+ )
142
+ return revisions
143
+
144
+
145
+ def write_vendor_lock(
146
+ path: Path,
147
+ dependencies: dict[str, VendorDependency],
148
+ revisions: dict[str, str],
149
+ ) -> None:
150
+ lines = [f"lockfileVersion: {LOCKFILE_VERSION}", "", "dependencies:"]
151
+ for name in dependencies:
152
+ revision = revisions.get(name)
153
+ if revision is not None:
154
+ if REVISION_PATTERN.fullmatch(revision) is None:
155
+ raise VendorConfigError(
156
+ f"Invalid revision for dependency {name!r}: {revision!r}."
157
+ )
158
+ lines.append(f" {name}: '{revision}'")
159
+ try:
160
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
161
+ except OSError as exc:
162
+ raise VendorConfigError(f"Could not write {path}: {exc}") from exc
@@ -0,0 +1,75 @@
1
+ """Executable and version checks required by pl-vendor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ MINIMUM_GIT_VERSION = (2, 30, 0)
11
+ MINIMUM_UV_VERSION = (0, 9, 0)
12
+ VERSION_PATTERN = re.compile(r"(?<!\d)(\d+(?:\.\d+)+)")
13
+
14
+
15
+ class PreflightError(RuntimeError):
16
+ """Raised when a required executable is unavailable or too old."""
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class ExecutableBackend:
21
+ """An executable that prepares or runs the pl-vendor environment."""
22
+
23
+ executable: str
24
+ minimum_version: tuple[int, ...]
25
+ version_arguments: tuple[str, ...] = ("--version",)
26
+
27
+ @property
28
+ def minimum_version_text(self) -> str:
29
+ return ".".join(str(part) for part in self.minimum_version)
30
+
31
+
32
+ UV_BACKEND = ExecutableBackend("uv", MINIMUM_UV_VERSION)
33
+ GIT_BACKEND = ExecutableBackend("git", MINIMUM_GIT_VERSION)
34
+
35
+
36
+ def _version_is_sufficient(
37
+ installed: tuple[int, ...], minimum: tuple[int, ...]
38
+ ) -> bool:
39
+ width = max(len(installed), len(minimum))
40
+ return installed + (0,) * (width - len(installed)) >= minimum + (0,) * (
41
+ width - len(minimum)
42
+ )
43
+
44
+
45
+ def check_executable(backend: ExecutableBackend, *, cwd: Path) -> tuple[int, ...]:
46
+ """Return an executable's parsed version after enforcing its minimum."""
47
+ try:
48
+ result = subprocess.run(
49
+ [backend.executable, *backend.version_arguments],
50
+ cwd=cwd,
51
+ check=False,
52
+ text=True,
53
+ capture_output=True,
54
+ )
55
+ except FileNotFoundError as exc:
56
+ raise PreflightError(
57
+ f"Required executable {backend.executable!r} was not found; "
58
+ f"install version {backend.minimum_version_text} or newer."
59
+ ) from exc
60
+
61
+ version_output = f"{result.stdout}\n{result.stderr}"
62
+ match = VERSION_PATTERN.search(version_output)
63
+ if result.returncode != 0 or match is None:
64
+ raise PreflightError(
65
+ f"Could not determine the installed {backend.executable!r} version."
66
+ )
67
+
68
+ installed = tuple(int(part) for part in match.group(1).split("."))
69
+ if not _version_is_sufficient(installed, backend.minimum_version):
70
+ installed_text = ".".join(str(part) for part in installed)
71
+ raise PreflightError(
72
+ f"{backend.executable!r} {installed_text} is too old; "
73
+ f"version {backend.minimum_version_text} or newer is required."
74
+ )
75
+ return installed