h2h-dev-tools 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- h2h_dev_tools/__init__.py +1 -0
- h2h_dev_tools/__main__.py +43 -0
- h2h_dev_tools/atomic_files.py +32 -0
- h2h_dev_tools/bundle.py +107 -0
- h2h_dev_tools/bundle_verify.py +255 -0
- h2h_dev_tools/change_scope.py +167 -0
- h2h_dev_tools/ci_dependencies.py +70 -0
- h2h_dev_tools/dependency_audit.py +220 -0
- h2h_dev_tools/dependency_manifest.py +23 -0
- h2h_dev_tools/distribution_check.py +70 -0
- h2h_dev_tools/documentation.py +175 -0
- h2h_dev_tools/formal.py +209 -0
- h2h_dev_tools/git-flow-merge.sh +263 -0
- h2h_dev_tools/install-git-hooks.sh +34 -0
- h2h_dev_tools/migrate_v1.py +61 -0
- h2h_dev_tools/offline_wheel.py +138 -0
- h2h_dev_tools/primary-branch.sh +52 -0
- h2h_dev_tools/process.py +873 -0
- h2h_dev_tools/publish_version.py +106 -0
- h2h_dev_tools/rebuild-env.sh +80 -0
- h2h_dev_tools/version_policy.py +391 -0
- h2h_dev_tools-0.2.1.dist-info/METADATA +221 -0
- h2h_dev_tools-0.2.1.dist-info/RECORD +26 -0
- h2h_dev_tools-0.2.1.dist-info/WHEEL +4 -0
- h2h_dev_tools-0.2.1.dist-info/entry_points.txt +2 -0
- h2h_dev_tools-0.2.1.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared, repository-independent development mechanisms."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Explicit source synchronization and offline verification commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .bundle import sync_bundle
|
|
10
|
+
from .bundle_verify import BundleError, verify_bundle
|
|
11
|
+
from .migrate_v1 import migrate_v1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(arguments: list[str] | None = None) -> int:
|
|
15
|
+
"""Execute one toolkit operation against an explicitly selected repository."""
|
|
16
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
17
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
18
|
+
for name in ("sync", "verify", "migrate-v1"):
|
|
19
|
+
command = commands.add_parser(name)
|
|
20
|
+
command.add_argument("--repository", required=True, type=Path)
|
|
21
|
+
options = parser.parse_args(arguments)
|
|
22
|
+
try:
|
|
23
|
+
if options.command in {"sync", "migrate-v1"}:
|
|
24
|
+
operation = sync_bundle if options.command == "sync" else migrate_v1
|
|
25
|
+
changed = operation(options.repository)
|
|
26
|
+
print(
|
|
27
|
+
"Source bundle synchronized."
|
|
28
|
+
if changed
|
|
29
|
+
else "Source bundle is unchanged."
|
|
30
|
+
)
|
|
31
|
+
else:
|
|
32
|
+
manifest = verify_bundle(options.repository)
|
|
33
|
+
print(
|
|
34
|
+
f"Verified h2h-dev-tools {manifest['toolkit_version']} source bundle."
|
|
35
|
+
)
|
|
36
|
+
except BundleError as error:
|
|
37
|
+
print(f"h2h-dev-tools: {error}", file=sys.stderr)
|
|
38
|
+
return 1
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Publish complete byte payloads through an atomic filesystem name operation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def publish_bytes(path: Path, content: bytes, *, overwrite: bool = False) -> None:
|
|
11
|
+
"""Publish in an existing parent, preserving other writers unless requested.
|
|
12
|
+
|
|
13
|
+
The temporary file is flushed and fsynced before publication. This provides
|
|
14
|
+
atomic visibility, not a directory-fsync or power-loss durability contract.
|
|
15
|
+
Callers own parent creation, serialization, target policy and permissions.
|
|
16
|
+
"""
|
|
17
|
+
temporary: Path | None = None
|
|
18
|
+
try:
|
|
19
|
+
with tempfile.NamedTemporaryFile(
|
|
20
|
+
mode="wb", dir=path.parent, prefix=f".{path.name}.", delete=False
|
|
21
|
+
) as stream:
|
|
22
|
+
temporary = Path(stream.name)
|
|
23
|
+
stream.write(content)
|
|
24
|
+
stream.flush()
|
|
25
|
+
os.fsync(stream.fileno())
|
|
26
|
+
if overwrite:
|
|
27
|
+
os.replace(temporary, path)
|
|
28
|
+
else:
|
|
29
|
+
os.link(temporary, path)
|
|
30
|
+
finally:
|
|
31
|
+
if temporary is not None:
|
|
32
|
+
temporary.unlink(missing_ok=True)
|
h2h_dev_tools/bundle.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Export reviewed toolkit source into a consumer's committed Git tree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from importlib import metadata
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .bundle_verify import (
|
|
14
|
+
MANIFEST_NAME,
|
|
15
|
+
BundleError,
|
|
16
|
+
BundleManifest,
|
|
17
|
+
bundle_directory,
|
|
18
|
+
cache_files,
|
|
19
|
+
component_files,
|
|
20
|
+
payload_digest,
|
|
21
|
+
regular_bytes,
|
|
22
|
+
repository_components,
|
|
23
|
+
validate_manifest,
|
|
24
|
+
verify_bundle,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _source_payload(components: list[str]) -> tuple[dict[str, bytes], BundleManifest]:
|
|
29
|
+
try:
|
|
30
|
+
version = metadata.version("h2h-dev-tools")
|
|
31
|
+
except metadata.PackageNotFoundError as error:
|
|
32
|
+
raise BundleError(
|
|
33
|
+
"Install h2h-dev-tools before exporting its source bundle"
|
|
34
|
+
) from error
|
|
35
|
+
source = Path(__file__).parent
|
|
36
|
+
payload = {
|
|
37
|
+
name: regular_bytes(source / name)
|
|
38
|
+
for name in sorted(component_files(components))
|
|
39
|
+
}
|
|
40
|
+
hashes = {
|
|
41
|
+
name: hashlib.sha256(content).hexdigest() for name, content in payload.items()
|
|
42
|
+
}
|
|
43
|
+
manifest: BundleManifest = {
|
|
44
|
+
"schema_version": 2,
|
|
45
|
+
"components": components,
|
|
46
|
+
"toolkit_name": "h2h-dev-tools",
|
|
47
|
+
"toolkit_version": version,
|
|
48
|
+
"payload_sha256": payload_digest(hashes),
|
|
49
|
+
"files": hashes,
|
|
50
|
+
}
|
|
51
|
+
return payload, validate_manifest(manifest)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sync_bundle(repository: Path) -> bool:
|
|
55
|
+
"""Replace only a verified generated file set; return whether bytes changed."""
|
|
56
|
+
return replace_bundle(
|
|
57
|
+
repository, lambda: verify_bundle(repository, check_selection=False)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def replace_bundle(
|
|
62
|
+
repository: Path, verify_existing: Callable[[], BundleManifest]
|
|
63
|
+
) -> bool:
|
|
64
|
+
"""Stage a replacement after verifying ownership, then recheck before writing."""
|
|
65
|
+
directory = bundle_directory(repository)
|
|
66
|
+
old_manifest: BundleManifest | None = None
|
|
67
|
+
try:
|
|
68
|
+
if directory.exists() and any(directory.iterdir()):
|
|
69
|
+
old_manifest = verify_existing()
|
|
70
|
+
payload, manifest = _source_payload(repository_components(repository))
|
|
71
|
+
if old_manifest == manifest:
|
|
72
|
+
return False
|
|
73
|
+
payload[MANIFEST_NAME] = (
|
|
74
|
+
json.dumps(manifest, indent=2, sort_keys=True) + "\n"
|
|
75
|
+
).encode("utf-8")
|
|
76
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
with tempfile.TemporaryDirectory(
|
|
78
|
+
prefix=".h2h-dev-tools-", dir=directory.parent
|
|
79
|
+
) as temporary:
|
|
80
|
+
staging = Path(temporary)
|
|
81
|
+
for name, content in payload.items():
|
|
82
|
+
(staging / name).write_bytes(content)
|
|
83
|
+
# Recheck existing authority after preparing the replacement payload.
|
|
84
|
+
if old_manifest is not None:
|
|
85
|
+
if verify_existing() != old_manifest:
|
|
86
|
+
raise BundleError("Bundle changed while preparing its update")
|
|
87
|
+
elif any(directory.iterdir()):
|
|
88
|
+
raise BundleError("Initial bundle directory is no longer empty")
|
|
89
|
+
bundle_directory(repository)
|
|
90
|
+
for cache in cache_files(
|
|
91
|
+
directory,
|
|
92
|
+
frozenset(old_manifest["files"]) if old_manifest else frozenset(),
|
|
93
|
+
):
|
|
94
|
+
cache.unlink()
|
|
95
|
+
cache_directory = directory / "__pycache__"
|
|
96
|
+
if cache_directory.exists():
|
|
97
|
+
cache_directory.rmdir()
|
|
98
|
+
for name in sorted(manifest["files"]):
|
|
99
|
+
os.replace(staging / name, directory / name)
|
|
100
|
+
if old_manifest is not None:
|
|
101
|
+
for name in sorted(set(old_manifest["files"]) - set(manifest["files"])):
|
|
102
|
+
(directory / name).unlink()
|
|
103
|
+
os.replace(staging / MANIFEST_NAME, directory / MANIFEST_NAME)
|
|
104
|
+
verify_bundle(repository)
|
|
105
|
+
except OSError as error:
|
|
106
|
+
raise BundleError(f"Cannot synchronize source bundle: {error}") from error
|
|
107
|
+
return True
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Verify the complete, committed source bundle without external dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
import tomllib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TypedDict, cast
|
|
14
|
+
|
|
15
|
+
BUNDLE_DIRECTORY = Path("scripts/_shared/h2h_dev_tools")
|
|
16
|
+
MANIFEST_NAME = "manifest.json"
|
|
17
|
+
BASE_FILES = frozenset({"__init__.py", "bundle_verify.py"})
|
|
18
|
+
COMPONENT_FILES: dict[str, frozenset[str]] = {
|
|
19
|
+
"atomic-files": frozenset({"atomic_files.py"}),
|
|
20
|
+
"change-scope": frozenset({"change_scope.py"}),
|
|
21
|
+
"documentation": frozenset({"documentation.py", "change_scope.py"}),
|
|
22
|
+
"dependencies": frozenset({"dependency_manifest.py", "ci_dependencies.py"}),
|
|
23
|
+
"dependency-audit": frozenset({"dependency_manifest.py", "dependency_audit.py"}),
|
|
24
|
+
"version-policy": frozenset({"dependency_manifest.py", "version_policy.py"}),
|
|
25
|
+
"publish-version": frozenset({"publish_version.py"}),
|
|
26
|
+
"process": frozenset({"process.py"}),
|
|
27
|
+
"distribution-check": frozenset({"distribution_check.py"}),
|
|
28
|
+
"offline-wheel": frozenset({"offline_wheel.py"}),
|
|
29
|
+
"formal": frozenset({"formal.py"}),
|
|
30
|
+
"primary-branch": frozenset({"primary-branch.sh"}),
|
|
31
|
+
"git-flow": frozenset({"primary-branch.sh", "git-flow-merge.sh"}),
|
|
32
|
+
"hooks": frozenset({"primary-branch.sh", "install-git-hooks.sh"}),
|
|
33
|
+
"environment": frozenset({"rebuild-env.sh"}),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def component_files(components: object) -> frozenset[str]:
|
|
38
|
+
"""Resolve a canonical component selection to its complete dependency closure."""
|
|
39
|
+
if not isinstance(components, list) or not all(
|
|
40
|
+
isinstance(c, str) for c in components
|
|
41
|
+
):
|
|
42
|
+
raise BundleError("Bundle components must be a list of names")
|
|
43
|
+
if (
|
|
44
|
+
components != sorted(set(components))
|
|
45
|
+
or not set(components) <= COMPONENT_FILES.keys()
|
|
46
|
+
):
|
|
47
|
+
raise BundleError("Bundle components must be unique, sorted, known names")
|
|
48
|
+
return BASE_FILES.union(*(COMPONENT_FILES[c] for c in components))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def repository_components(repository: Path) -> list[str]:
|
|
52
|
+
"""Require explicit selection; syncing never guesses consumer requirements."""
|
|
53
|
+
try:
|
|
54
|
+
document = tomllib.loads(
|
|
55
|
+
regular_bytes(repository / "pyproject.toml").decode("utf-8")
|
|
56
|
+
)
|
|
57
|
+
components = document["tool"]["h2h-dev-tools"]["components"]
|
|
58
|
+
except (KeyError, UnicodeError, tomllib.TOMLDecodeError) as error:
|
|
59
|
+
raise BundleError(
|
|
60
|
+
"Declare [tool.h2h-dev-tools].components in pyproject.toml"
|
|
61
|
+
) from error
|
|
62
|
+
if not isinstance(components, list) or not all(
|
|
63
|
+
isinstance(c, str) for c in components
|
|
64
|
+
):
|
|
65
|
+
raise BundleError("Configured components must be a list of names")
|
|
66
|
+
if len(components) != len(set(components)):
|
|
67
|
+
raise BundleError("Configured components contain duplicates")
|
|
68
|
+
result = sorted(components)
|
|
69
|
+
component_files(result)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_SHA256 = re.compile(r"[0-9a-f]{64}")
|
|
74
|
+
_VERSION = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)")
|
|
75
|
+
_CACHE_NAME = re.compile(
|
|
76
|
+
r"(?P<module>[A-Za-z_][A-Za-z_0-9]*)\.cpython-[0-9]+(?:\.opt-[12])?\.pyc"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class BundleError(RuntimeError):
|
|
81
|
+
"""A generated source bundle cannot be verified or safely replaced."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class BundleManifest(TypedDict):
|
|
85
|
+
"""The exact source payload and its producing toolkit release."""
|
|
86
|
+
|
|
87
|
+
schema_version: int
|
|
88
|
+
components: list[str]
|
|
89
|
+
toolkit_name: str
|
|
90
|
+
toolkit_version: str
|
|
91
|
+
payload_sha256: str
|
|
92
|
+
files: dict[str, str]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def payload_digest(files: dict[str, str]) -> str:
|
|
96
|
+
"""Fingerprint the named source bytes, not an unobserved wheel archive."""
|
|
97
|
+
encoded = json.dumps(files, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
98
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def bundle_directory(repository: Path) -> Path:
|
|
102
|
+
"""Reject symlinks in every repository-relative managed directory."""
|
|
103
|
+
root = repository.absolute()
|
|
104
|
+
try:
|
|
105
|
+
if not stat.S_ISDIR(root.lstat().st_mode):
|
|
106
|
+
raise BundleError(f"Repository must be a real directory: {root}")
|
|
107
|
+
current = root
|
|
108
|
+
for component in BUNDLE_DIRECTORY.parts:
|
|
109
|
+
current /= component
|
|
110
|
+
try:
|
|
111
|
+
mode = current.lstat().st_mode
|
|
112
|
+
except FileNotFoundError:
|
|
113
|
+
continue
|
|
114
|
+
if not stat.S_ISDIR(mode):
|
|
115
|
+
raise BundleError(
|
|
116
|
+
f"Managed bundle path must be a real directory: {current}"
|
|
117
|
+
)
|
|
118
|
+
except OSError as error:
|
|
119
|
+
raise BundleError(f"Cannot inspect bundle directories: {error}") from error
|
|
120
|
+
return root / BUNDLE_DIRECTORY
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
124
|
+
result: dict[str, object] = {}
|
|
125
|
+
for key, value in pairs:
|
|
126
|
+
if key in result:
|
|
127
|
+
raise BundleError(f"Duplicate manifest key: {key}")
|
|
128
|
+
result[key] = value
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def regular_bytes(path: Path) -> bytes:
|
|
133
|
+
"""Read a generated regular file without accepting a symlink entry."""
|
|
134
|
+
try:
|
|
135
|
+
if not stat.S_ISREG(path.lstat().st_mode):
|
|
136
|
+
raise BundleError(f"Bundle entry must be a regular file: {path}")
|
|
137
|
+
return path.read_bytes()
|
|
138
|
+
except OSError as error:
|
|
139
|
+
raise BundleError(f"Cannot read bundle file {path}: {error}") from error
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _read_manifest(path: Path) -> BundleManifest:
|
|
143
|
+
try:
|
|
144
|
+
document: object = json.loads(
|
|
145
|
+
regular_bytes(path), object_pairs_hook=_reject_duplicate_keys
|
|
146
|
+
)
|
|
147
|
+
except (UnicodeError, json.JSONDecodeError) as error:
|
|
148
|
+
raise BundleError(f"Invalid bundle manifest: {error}") from error
|
|
149
|
+
return validate_manifest(document)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def validate_manifest(document: object) -> BundleManifest:
|
|
153
|
+
"""Accept only the complete schema and safe, explicitly owned filenames."""
|
|
154
|
+
if not isinstance(document, dict) or set(document) != {
|
|
155
|
+
"schema_version",
|
|
156
|
+
"components",
|
|
157
|
+
"toolkit_name",
|
|
158
|
+
"toolkit_version",
|
|
159
|
+
"payload_sha256",
|
|
160
|
+
"files",
|
|
161
|
+
}:
|
|
162
|
+
raise BundleError("Bundle manifest fields do not match schema 2")
|
|
163
|
+
if type(document["schema_version"]) is not int or document["schema_version"] != 2:
|
|
164
|
+
raise BundleError("Unsupported bundle manifest schema")
|
|
165
|
+
if document["toolkit_name"] != "h2h-dev-tools":
|
|
166
|
+
raise BundleError("Bundle manifest belongs to another toolkit")
|
|
167
|
+
version = document["toolkit_version"]
|
|
168
|
+
if not isinstance(version, str) or _VERSION.fullmatch(version) is None:
|
|
169
|
+
raise BundleError("Bundle toolkit version must be X.Y.Z")
|
|
170
|
+
files = document["files"]
|
|
171
|
+
if not isinstance(files, dict) or set(files) != component_files(
|
|
172
|
+
document["components"]
|
|
173
|
+
):
|
|
174
|
+
raise BundleError("Bundle manifest must declare the complete source file set")
|
|
175
|
+
if not all(
|
|
176
|
+
isinstance(value, str) and _SHA256.fullmatch(value) for value in files.values()
|
|
177
|
+
):
|
|
178
|
+
raise BundleError("Invalid source file SHA-256 in bundle manifest")
|
|
179
|
+
if document["payload_sha256"] != payload_digest(files):
|
|
180
|
+
raise BundleError("Bundle payload fingerprint does not match its file manifest")
|
|
181
|
+
return cast(BundleManifest, document)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cache_files(directory: Path, files: frozenset[str]) -> tuple[Path, ...]:
|
|
185
|
+
"""Identify disposable CPython caches without accepting other extra content."""
|
|
186
|
+
cache = directory / "__pycache__"
|
|
187
|
+
try:
|
|
188
|
+
try:
|
|
189
|
+
mode = cache.lstat().st_mode
|
|
190
|
+
except FileNotFoundError:
|
|
191
|
+
return ()
|
|
192
|
+
if not stat.S_ISDIR(mode):
|
|
193
|
+
raise BundleError("Bundle __pycache__ must be a real directory")
|
|
194
|
+
entries = tuple(cache.iterdir())
|
|
195
|
+
for entry in entries:
|
|
196
|
+
match = _CACHE_NAME.fullmatch(entry.name)
|
|
197
|
+
if (
|
|
198
|
+
match is None
|
|
199
|
+
or f"{match['module']}.py" not in files
|
|
200
|
+
or not stat.S_ISREG(entry.lstat().st_mode)
|
|
201
|
+
):
|
|
202
|
+
raise BundleError(f"Unknown or nonregular bundle cache entry: {entry}")
|
|
203
|
+
return entries
|
|
204
|
+
except OSError as error:
|
|
205
|
+
raise BundleError(f"Cannot inspect bundle bytecode caches: {error}") from error
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def verify_bundle(repository: Path, *, check_selection: bool = True) -> BundleManifest:
|
|
209
|
+
"""Prove that each declared file matches and that no extra entry exists."""
|
|
210
|
+
directory = bundle_directory(repository)
|
|
211
|
+
manifest = _read_manifest(directory / MANIFEST_NAME)
|
|
212
|
+
if check_selection and manifest["components"] != repository_components(repository):
|
|
213
|
+
raise BundleError("Bundle component selection is stale; run explicit sync")
|
|
214
|
+
return verify_payload(repository, manifest)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def verify_payload(repository: Path, manifest: BundleManifest) -> BundleManifest:
|
|
218
|
+
"""Verify an already validated manifest against the complete managed directory."""
|
|
219
|
+
directory = bundle_directory(repository)
|
|
220
|
+
try:
|
|
221
|
+
actual = {entry.name for entry in directory.iterdir()}
|
|
222
|
+
except OSError as error:
|
|
223
|
+
raise BundleError(f"Cannot inspect bundle file closure: {error}") from error
|
|
224
|
+
files = frozenset(manifest["files"])
|
|
225
|
+
expected = files | {MANIFEST_NAME}
|
|
226
|
+
cache_files(directory, files)
|
|
227
|
+
actual.discard("__pycache__")
|
|
228
|
+
if actual != expected:
|
|
229
|
+
raise BundleError(
|
|
230
|
+
f"Bundle file closure mismatch; missing={sorted(expected - actual)}, "
|
|
231
|
+
f"extra={sorted(actual - expected)}"
|
|
232
|
+
)
|
|
233
|
+
for name, expected_hash in manifest["files"].items():
|
|
234
|
+
actual_hash = hashlib.sha256(regular_bytes(directory / name)).hexdigest()
|
|
235
|
+
if actual_hash != expected_hash:
|
|
236
|
+
raise BundleError(f"Generated source was modified: {name}")
|
|
237
|
+
return manifest
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def main(arguments: list[str] | None = None) -> int:
|
|
241
|
+
"""Provide a standalone consumer-side verification entry point."""
|
|
242
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
243
|
+
parser.add_argument("--repository", required=True, type=Path)
|
|
244
|
+
options = parser.parse_args(arguments)
|
|
245
|
+
try:
|
|
246
|
+
manifest = verify_bundle(options.repository)
|
|
247
|
+
except BundleError as error:
|
|
248
|
+
print(f"h2h-dev-tools: {error}", file=sys.stderr)
|
|
249
|
+
return 1
|
|
250
|
+
print(f"Verified h2h-dev-tools {manifest['toolkit_version']} source bundle.")
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Classify an exact Git tree difference for documentation-only checks."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
ChangeScope = Literal["documentation", "full"]
|
|
14
|
+
_DOCUMENT_PATHS = frozenset(
|
|
15
|
+
{"README.md", "benchmarks/README.md", "verification/README.md"}
|
|
16
|
+
)
|
|
17
|
+
_OBJECT_ID = re.compile(r"[0-9a-f]{40}(?:[0-9a-f]{24})?")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChangeScopeError(RuntimeError):
|
|
21
|
+
"""The exact change set could not be established safely."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def git_output(root: Path, *arguments: str) -> bytes:
|
|
25
|
+
"""Read Git output without interpreting filenames as lines or shell code."""
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
("git", *arguments),
|
|
30
|
+
cwd=root,
|
|
31
|
+
check=True,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
timeout=60,
|
|
34
|
+
)
|
|
35
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
36
|
+
detail = (
|
|
37
|
+
(error.stderr or error.stdout).decode("utf-8", errors="replace").strip()
|
|
38
|
+
if isinstance(error, subprocess.CalledProcessError)
|
|
39
|
+
else str(error)
|
|
40
|
+
)
|
|
41
|
+
raise ChangeScopeError(f"Git operation failed: {detail}") from error
|
|
42
|
+
return result.stdout
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_tree(root: Path, revision: str) -> str:
|
|
46
|
+
"""Resolve a commit or tree to one immutable tree object ID."""
|
|
47
|
+
|
|
48
|
+
output = git_output(
|
|
49
|
+
root, "rev-parse", "--verify", "--end-of-options", f"{revision}^{{tree}}"
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
tree = output.decode("ascii").strip()
|
|
53
|
+
except UnicodeError as error:
|
|
54
|
+
raise ChangeScopeError("Git returned an invalid tree object ID") from error
|
|
55
|
+
if _OBJECT_ID.fullmatch(tree) is None:
|
|
56
|
+
raise ChangeScopeError("Git returned an invalid tree object ID")
|
|
57
|
+
return tree
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def capture_candidate(root: Path, revision: str | None) -> str:
|
|
61
|
+
"""Capture either the index or an explicitly requested revision."""
|
|
62
|
+
|
|
63
|
+
if revision is not None:
|
|
64
|
+
return resolve_tree(root, revision)
|
|
65
|
+
try:
|
|
66
|
+
tree = git_output(root, "write-tree").decode("ascii").strip()
|
|
67
|
+
except UnicodeError as error:
|
|
68
|
+
raise ChangeScopeError("Git returned an invalid index tree") from error
|
|
69
|
+
return resolve_tree(root, tree)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_document(path: str) -> bool:
|
|
73
|
+
parts = PurePosixPath(path).parts
|
|
74
|
+
if parts and parts[-1].casefold() in {"agents.md", "claude.md"}:
|
|
75
|
+
return False
|
|
76
|
+
return path in _DOCUMENT_PATHS or (
|
|
77
|
+
len(parts) >= 2 and parts[0] == "docs" and path.endswith(".md")
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_text(root: Path, object_id: str) -> bool:
|
|
82
|
+
content = git_output(root, "cat-file", "blob", object_id)
|
|
83
|
+
if b"\0" in content:
|
|
84
|
+
return False
|
|
85
|
+
try:
|
|
86
|
+
content.decode("utf-8")
|
|
87
|
+
except UnicodeError:
|
|
88
|
+
return False
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def classify_changes(base: str, candidate: str, root: Path) -> ChangeScope:
|
|
93
|
+
"""Allow only registered, non-executable text documents into the light gate."""
|
|
94
|
+
|
|
95
|
+
base_tree = resolve_tree(root, base)
|
|
96
|
+
candidate_tree = resolve_tree(root, candidate)
|
|
97
|
+
raw = git_output(
|
|
98
|
+
root,
|
|
99
|
+
"diff",
|
|
100
|
+
"--raw",
|
|
101
|
+
"--no-renames",
|
|
102
|
+
"--no-ext-diff",
|
|
103
|
+
"--no-textconv",
|
|
104
|
+
"--ignore-submodules=none",
|
|
105
|
+
"--no-abbrev",
|
|
106
|
+
"-z",
|
|
107
|
+
base_tree,
|
|
108
|
+
candidate_tree,
|
|
109
|
+
"--",
|
|
110
|
+
)
|
|
111
|
+
if not raw:
|
|
112
|
+
return "full"
|
|
113
|
+
fields = raw.split(b"\0")
|
|
114
|
+
if fields.pop() != b"" or len(fields) % 2:
|
|
115
|
+
raise ChangeScopeError("Malformed NUL-delimited Git difference")
|
|
116
|
+
for index in range(0, len(fields), 2):
|
|
117
|
+
try:
|
|
118
|
+
header = fields[index].decode("ascii").split()
|
|
119
|
+
path = fields[index + 1].decode("utf-8")
|
|
120
|
+
except UnicodeError:
|
|
121
|
+
return "full"
|
|
122
|
+
if len(header) != 5 or not header[0].startswith(":"):
|
|
123
|
+
raise ChangeScopeError("Malformed raw Git difference entry")
|
|
124
|
+
old_mode, new_mode, old_id, new_id, status = header
|
|
125
|
+
modes = (old_mode[1:], new_mode)
|
|
126
|
+
expected = {
|
|
127
|
+
"A": ("000000", "100644"),
|
|
128
|
+
"M": ("100644", "100644"),
|
|
129
|
+
"D": ("100644", "000000"),
|
|
130
|
+
}
|
|
131
|
+
if not _is_document(path) or modes != expected.get(status):
|
|
132
|
+
return "full"
|
|
133
|
+
for mode, object_id in zip(modes, (old_id, new_id), strict=True):
|
|
134
|
+
if mode != "000000" and not _is_text(root, object_id):
|
|
135
|
+
return "full"
|
|
136
|
+
return "documentation"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def add_target_arguments(parser: argparse.ArgumentParser) -> None:
|
|
140
|
+
targets = parser.add_mutually_exclusive_group(required=True)
|
|
141
|
+
targets.add_argument("--index", action="store_true", help="check the staged tree")
|
|
142
|
+
targets.add_argument("--candidate", help="check this commit or tree")
|
|
143
|
+
parser.add_argument("--base", default="HEAD", help="comparison commit or tree")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main() -> int:
|
|
147
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
148
|
+
add_target_arguments(parser)
|
|
149
|
+
arguments = parser.parse_args()
|
|
150
|
+
root = Path.cwd()
|
|
151
|
+
base = resolve_tree(root, arguments.base)
|
|
152
|
+
candidate = capture_candidate(root, arguments.candidate)
|
|
153
|
+
scope = classify_changes(base, candidate, root)
|
|
154
|
+
if capture_candidate(root, arguments.candidate) != candidate:
|
|
155
|
+
raise ChangeScopeError("Candidate changed while classifying its scope")
|
|
156
|
+
if resolve_tree(root, arguments.base) != base:
|
|
157
|
+
raise ChangeScopeError("Base changed while classifying the candidate scope")
|
|
158
|
+
print(scope)
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
try:
|
|
164
|
+
raise SystemExit(main())
|
|
165
|
+
except ChangeScopeError as error:
|
|
166
|
+
print(f"check-change-scope: {error}", file=sys.stderr)
|
|
167
|
+
raise SystemExit(1) from error
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Install a CI dependency subset using the project's declared requirements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tomllib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _package_name(requirement: str) -> str:
|
|
14
|
+
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
|
|
15
|
+
if match is None:
|
|
16
|
+
raise ValueError(f"Invalid dependency name: {requirement!r}")
|
|
17
|
+
return re.sub(r"[-_.]+", "-", match[0]).lower()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def requirements_for(
|
|
21
|
+
manifest: Path, packages: list[str], *, pytest_plugins: bool
|
|
22
|
+
) -> list[str]:
|
|
23
|
+
with manifest.open("rb") as stream:
|
|
24
|
+
document = tomllib.load(stream)
|
|
25
|
+
project = document["project"]
|
|
26
|
+
declared = [
|
|
27
|
+
*project.get("dependencies", []),
|
|
28
|
+
*project.get("optional-dependencies", {}).get("dev", []),
|
|
29
|
+
]
|
|
30
|
+
requirements: list[str] = []
|
|
31
|
+
for package in packages:
|
|
32
|
+
matches = [
|
|
33
|
+
requirement
|
|
34
|
+
for requirement in declared
|
|
35
|
+
if _package_name(requirement) == _package_name(package)
|
|
36
|
+
]
|
|
37
|
+
if not matches:
|
|
38
|
+
raise ValueError(f"Dependency is not declared in {manifest}: {package}")
|
|
39
|
+
requirements.extend(matches)
|
|
40
|
+
if pytest_plugins:
|
|
41
|
+
requirements.extend(
|
|
42
|
+
document["tool"]["pytest"]["ini_options"]["required_plugins"]
|
|
43
|
+
)
|
|
44
|
+
return list(dict.fromkeys(requirements))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main(repository: Path | None = None) -> None:
|
|
48
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
49
|
+
parser.add_argument("packages", nargs="+", help="runtime or dev dependency names")
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--pytest-plugins",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="also install every plugin required by the repository pytest settings",
|
|
54
|
+
)
|
|
55
|
+
arguments = parser.parse_args()
|
|
56
|
+
manifest = (Path.cwd() if repository is None else repository) / "pyproject.toml"
|
|
57
|
+
try:
|
|
58
|
+
requirements = requirements_for(
|
|
59
|
+
manifest, arguments.packages, pytest_plugins=arguments.pytest_plugins
|
|
60
|
+
)
|
|
61
|
+
except (KeyError, OSError, ValueError) as error:
|
|
62
|
+
parser.error(str(error))
|
|
63
|
+
subprocess.run(
|
|
64
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", *requirements],
|
|
65
|
+
check=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|