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.
- pl_vendor-0.1.0/PKG-INFO +75 -0
- pl_vendor-0.1.0/README.md +68 -0
- pl_vendor-0.1.0/pyproject.toml +36 -0
- pl_vendor-0.1.0/setup.cfg +4 -0
- pl_vendor-0.1.0/src/pl_vendor/__init__.py +32 -0
- pl_vendor-0.1.0/src/pl_vendor/__main__.py +3 -0
- pl_vendor-0.1.0/src/pl_vendor/cli.py +59 -0
- pl_vendor-0.1.0/src/pl_vendor/config.py +162 -0
- pl_vendor-0.1.0/src/pl_vendor/preflight.py +75 -0
- pl_vendor-0.1.0/src/pl_vendor/project.py +488 -0
- pl_vendor-0.1.0/src/pl_vendor/tests/__init__.py +1 -0
- pl_vendor-0.1.0/src/pl_vendor/tests/test_config.py +180 -0
- pl_vendor-0.1.0/src/pl_vendor/tests/test_preflight.py +28 -0
- pl_vendor-0.1.0/src/pl_vendor/tests/test_project.py +129 -0
- pl_vendor-0.1.0/src/pl_vendor.egg-info/PKG-INFO +75 -0
- pl_vendor-0.1.0/src/pl_vendor.egg-info/SOURCES.txt +17 -0
- pl_vendor-0.1.0/src/pl_vendor.egg-info/dependency_links.txt +1 -0
- pl_vendor-0.1.0/src/pl_vendor.egg-info/entry_points.txt +2 -0
- pl_vendor-0.1.0/src/pl_vendor.egg-info/top_level.txt +1 -0
pl_vendor-0.1.0/PKG-INFO
ADDED
|
@@ -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,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,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
|