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,220 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audit every direct Python and Node dependency and write a review receipt."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tomllib
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from functools import cache
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, cast
|
|
19
|
+
|
|
20
|
+
from packaging.requirements import Requirement
|
|
21
|
+
from packaging.specifiers import SpecifierSet
|
|
22
|
+
from packaging.version import Version
|
|
23
|
+
|
|
24
|
+
from .dependency_manifest import dependency_manifest
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _python_requirements(document: dict[str, Any]) -> list[tuple[str, str]]:
|
|
28
|
+
project = document.get("project", {})
|
|
29
|
+
grouped: list[tuple[str, list[str]]] = [
|
|
30
|
+
("build", document.get("build-system", {}).get("requires", [])),
|
|
31
|
+
("runtime", project.get("dependencies", [])),
|
|
32
|
+
]
|
|
33
|
+
grouped.extend(
|
|
34
|
+
(f"optional:{name}", requirements)
|
|
35
|
+
for name, requirements in project.get("optional-dependencies", {}).items()
|
|
36
|
+
)
|
|
37
|
+
grouped.extend(
|
|
38
|
+
(f"group:{name}", requirements)
|
|
39
|
+
for name, requirements in document.get("dependency-groups", {}).items()
|
|
40
|
+
)
|
|
41
|
+
return [
|
|
42
|
+
(group, requirement)
|
|
43
|
+
for group, requirements in grouped
|
|
44
|
+
for requirement in requirements
|
|
45
|
+
if isinstance(requirement, str)
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@cache
|
|
50
|
+
def _pypi_json(name: str) -> dict[str, Any]:
|
|
51
|
+
normalized = urllib.parse.quote(name, safe="")
|
|
52
|
+
request = urllib.request.Request(
|
|
53
|
+
f"https://pypi.org/pypi/{normalized}/json",
|
|
54
|
+
headers={"User-Agent": "h2h-dependency-audit/1"},
|
|
55
|
+
)
|
|
56
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
57
|
+
payload = json.load(response)
|
|
58
|
+
if not isinstance(payload, dict):
|
|
59
|
+
raise ValueError(f"invalid PyPI response for {name}")
|
|
60
|
+
return cast(dict[str, Any], payload)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _supports_python_314(files: list[dict[str, Any]]) -> bool:
|
|
64
|
+
for file in files:
|
|
65
|
+
if file.get("yanked"):
|
|
66
|
+
continue
|
|
67
|
+
requires_python = file.get("requires_python")
|
|
68
|
+
if not requires_python or Version("3.14") in SpecifierSet(requires_python):
|
|
69
|
+
return True
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _release_notes_url(info: dict[str, Any]) -> str | None:
|
|
74
|
+
project_urls = info.get("project_urls")
|
|
75
|
+
if not isinstance(project_urls, dict):
|
|
76
|
+
return None
|
|
77
|
+
for label, url in project_urls.items():
|
|
78
|
+
normalized = str(label).casefold().replace("_", " ").replace("-", " ")
|
|
79
|
+
if normalized in {
|
|
80
|
+
"changelog",
|
|
81
|
+
"changes",
|
|
82
|
+
"history",
|
|
83
|
+
"release notes",
|
|
84
|
+
} and isinstance(url, str):
|
|
85
|
+
return url
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _audit_python(group: str, declared: str) -> dict[str, Any]:
|
|
90
|
+
requirement = Requirement(declared)
|
|
91
|
+
if requirement.url is not None:
|
|
92
|
+
return {
|
|
93
|
+
"declared": declared,
|
|
94
|
+
"group": group,
|
|
95
|
+
"latest": None,
|
|
96
|
+
"latest_satisfies": None,
|
|
97
|
+
"name": requirement.name,
|
|
98
|
+
"note": "direct URL/ref requires manual upstream review",
|
|
99
|
+
"python_3_14": None,
|
|
100
|
+
}
|
|
101
|
+
payload = _pypi_json(requirement.name)
|
|
102
|
+
info = payload["info"]
|
|
103
|
+
latest = str(info["version"])
|
|
104
|
+
return {
|
|
105
|
+
"declared": declared,
|
|
106
|
+
"group": group,
|
|
107
|
+
"latest": latest,
|
|
108
|
+
"latest_satisfies": Version(latest) in requirement.specifier,
|
|
109
|
+
"name": requirement.name,
|
|
110
|
+
"python_3_14": _supports_python_314(payload["releases"].get(latest, [])),
|
|
111
|
+
"registry_url": info.get("package_url"),
|
|
112
|
+
"release_notes": _release_notes_url(info),
|
|
113
|
+
"url": info.get("project_url") or info.get("package_url"),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _npm_view_version(specification: str) -> str | list[str]:
|
|
118
|
+
completed = subprocess.run(
|
|
119
|
+
("npm", "view", specification, "version", "--json"),
|
|
120
|
+
check=True,
|
|
121
|
+
stdout=subprocess.PIPE,
|
|
122
|
+
text=True,
|
|
123
|
+
)
|
|
124
|
+
value = json.loads(completed.stdout)
|
|
125
|
+
if not isinstance(value, (str, list)) or (
|
|
126
|
+
isinstance(value, list)
|
|
127
|
+
and not all(isinstance(version, str) for version in value)
|
|
128
|
+
):
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"invalid latest version or matching versions from npm for {specification}"
|
|
131
|
+
)
|
|
132
|
+
return cast(str | list[str], value)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _audit_node(name: str, declared: str) -> dict[str, Any]:
|
|
136
|
+
latest_value = _npm_view_version(name)
|
|
137
|
+
if not isinstance(latest_value, str) or not latest_value:
|
|
138
|
+
raise ValueError(f"invalid latest version from npm for {name}")
|
|
139
|
+
matching_value = _npm_view_version(f"{name}@{declared}")
|
|
140
|
+
matching_versions = (
|
|
141
|
+
[matching_value] if isinstance(matching_value, str) else matching_value
|
|
142
|
+
)
|
|
143
|
+
return {
|
|
144
|
+
"declared": declared,
|
|
145
|
+
"latest": latest_value,
|
|
146
|
+
"latest_satisfies": latest_value in matching_versions,
|
|
147
|
+
"name": name,
|
|
148
|
+
"registry_url": f"https://www.npmjs.com/package/{name}",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main() -> int:
|
|
153
|
+
parser = argparse.ArgumentParser()
|
|
154
|
+
parser.add_argument("--review-note", required=True)
|
|
155
|
+
parser.add_argument(
|
|
156
|
+
"--output",
|
|
157
|
+
default=".release/dependency-audit.json",
|
|
158
|
+
type=Path,
|
|
159
|
+
)
|
|
160
|
+
arguments = parser.parse_args()
|
|
161
|
+
if not arguments.review_note.strip():
|
|
162
|
+
parser.error("--review-note must not be empty")
|
|
163
|
+
|
|
164
|
+
with Path("pyproject.toml").open("rb") as stream:
|
|
165
|
+
document = tomllib.load(stream)
|
|
166
|
+
package = json.loads(Path("package.json").read_text())
|
|
167
|
+
python_audit = [
|
|
168
|
+
_audit_python(group, requirement)
|
|
169
|
+
for group, requirement in _python_requirements(document)
|
|
170
|
+
]
|
|
171
|
+
node_audit = [
|
|
172
|
+
_audit_node(name, declared)
|
|
173
|
+
for name, declared in package.get("devDependencies", {}).items()
|
|
174
|
+
]
|
|
175
|
+
receipt = {
|
|
176
|
+
"checked_at": dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat(),
|
|
177
|
+
"manifest_sha256": hashlib.sha256(
|
|
178
|
+
dependency_manifest(document, package)
|
|
179
|
+
).hexdigest(),
|
|
180
|
+
"node": node_audit,
|
|
181
|
+
"project_version": document["project"]["version"],
|
|
182
|
+
"python": python_audit,
|
|
183
|
+
"review": {
|
|
184
|
+
"note": arguments.review_note.strip(),
|
|
185
|
+
"status": "reviewed",
|
|
186
|
+
},
|
|
187
|
+
"schema": "h2h.dependency-audit.v1",
|
|
188
|
+
}
|
|
189
|
+
arguments.output.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
arguments.output.write_text(
|
|
191
|
+
json.dumps(receipt, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
192
|
+
)
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
try:
|
|
198
|
+
raise SystemExit(main())
|
|
199
|
+
except urllib.error.HTTPError as error:
|
|
200
|
+
print(
|
|
201
|
+
f"audit-dependencies: registry HTTP {error.code} for "
|
|
202
|
+
f"{error.url}: {error.reason}",
|
|
203
|
+
file=sys.stderr,
|
|
204
|
+
)
|
|
205
|
+
raise SystemExit(1) from error
|
|
206
|
+
except urllib.error.URLError as error:
|
|
207
|
+
print(
|
|
208
|
+
f"audit-dependencies: registry request failed: {error.reason}",
|
|
209
|
+
file=sys.stderr,
|
|
210
|
+
)
|
|
211
|
+
raise SystemExit(1) from error
|
|
212
|
+
except json.JSONDecodeError as error:
|
|
213
|
+
print(
|
|
214
|
+
f"audit-dependencies: registry returned invalid JSON: {error}",
|
|
215
|
+
file=sys.stderr,
|
|
216
|
+
)
|
|
217
|
+
raise SystemExit(1) from error
|
|
218
|
+
except (OSError, subprocess.CalledProcessError, ValueError) as error:
|
|
219
|
+
print(f"audit-dependencies: {type(error).__name__}: {error}", file=sys.stderr)
|
|
220
|
+
raise SystemExit(1) from error
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Canonical dependency bytes shared by audit creation and verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def dependency_manifest(document: dict[str, Any], package: dict[str, Any]) -> bytes:
|
|
10
|
+
project = document.get("project", {})
|
|
11
|
+
manifest = {
|
|
12
|
+
"build": document.get("build-system", {}).get("requires", []),
|
|
13
|
+
"dependency-groups": document.get("dependency-groups", {}),
|
|
14
|
+
"optional": project.get("optional-dependencies", {}),
|
|
15
|
+
"runtime": project.get("dependencies", []),
|
|
16
|
+
"node-dev": package.get("devDependencies", {}),
|
|
17
|
+
}
|
|
18
|
+
return json.dumps(
|
|
19
|
+
manifest,
|
|
20
|
+
ensure_ascii=False,
|
|
21
|
+
sort_keys=True,
|
|
22
|
+
separators=(",", ":"),
|
|
23
|
+
).encode()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Verify installed requirements and wheel RECORD/import origins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from importlib.metadata import Distribution, PackageNotFoundError, distribution
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from packaging.requirements import Requirement
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_runtime_requirements(
|
|
15
|
+
package: Distribution, *, prereleases: bool | None = None
|
|
16
|
+
) -> dict[str, str]:
|
|
17
|
+
selected: dict[str, str] = {}
|
|
18
|
+
for raw in package.requires or ():
|
|
19
|
+
requirement = Requirement(raw)
|
|
20
|
+
if requirement.marker is not None and not requirement.marker.evaluate(
|
|
21
|
+
{"extra": ""}
|
|
22
|
+
):
|
|
23
|
+
continue
|
|
24
|
+
try:
|
|
25
|
+
dependency = distribution(requirement.name)
|
|
26
|
+
except PackageNotFoundError as error:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
f"{package.metadata['Name']} requires {requirement}; package is missing"
|
|
29
|
+
) from error
|
|
30
|
+
if not requirement.specifier.contains(
|
|
31
|
+
dependency.version, prereleases=prereleases
|
|
32
|
+
):
|
|
33
|
+
raise RuntimeError(
|
|
34
|
+
f"{package.metadata['Name']} requires {requirement}; "
|
|
35
|
+
f"installed {requirement.name}=={dependency.version} "
|
|
36
|
+
f"does not satisfy {requirement}"
|
|
37
|
+
)
|
|
38
|
+
selected[requirement.name] = dependency.version
|
|
39
|
+
return selected
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_wheel_origin(project: str, module_name: str, *, local: bool) -> str:
|
|
43
|
+
package = distribution(project)
|
|
44
|
+
module = importlib.import_module(module_name)
|
|
45
|
+
if module.__file__ is None:
|
|
46
|
+
raise RuntimeError(f"{project} has no import origin")
|
|
47
|
+
actual = Path(module.__file__).resolve()
|
|
48
|
+
record_name = module_name.replace(".", "/") + "/__init__.py"
|
|
49
|
+
expected = Path(str(package.locate_file(record_name))).resolve()
|
|
50
|
+
if actual != expected:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"{project} import disagrees with installed metadata: {actual}"
|
|
53
|
+
)
|
|
54
|
+
if package.files is None or not any(
|
|
55
|
+
str(item) == record_name for item in package.files
|
|
56
|
+
):
|
|
57
|
+
raise RuntimeError(f"{project} has no installed package RECORD")
|
|
58
|
+
direct = json.loads(package.read_text("direct_url.json") or "{}")
|
|
59
|
+
if direct.get("dir_info", {}).get("editable", False):
|
|
60
|
+
raise RuntimeError(
|
|
61
|
+
f"{project} must come from a wheel, not an editable checkout"
|
|
62
|
+
)
|
|
63
|
+
if local and (
|
|
64
|
+
not actual.is_relative_to(Path(sys.prefix).resolve())
|
|
65
|
+
or not Path(str(package.locate_file("")))
|
|
66
|
+
.resolve()
|
|
67
|
+
.is_relative_to(Path(sys.prefix).resolve())
|
|
68
|
+
):
|
|
69
|
+
raise RuntimeError(f"{project} must be installed inside the smoke environment")
|
|
70
|
+
return str(actual)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check documentation from an exact Git snapshot without program checks."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import posixpath
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tomllib
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
from tempfile import TemporaryDirectory
|
|
14
|
+
|
|
15
|
+
from .change_scope import (
|
|
16
|
+
ChangeScopeError,
|
|
17
|
+
add_target_arguments,
|
|
18
|
+
capture_candidate,
|
|
19
|
+
classify_changes,
|
|
20
|
+
git_output,
|
|
21
|
+
resolve_tree,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_CONFIG = ".markdownlint-cli2.jsonc"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _regular_blob(root: Path, tree: str, path: str) -> bytes | None:
|
|
28
|
+
raw = git_output(root, "--literal-pathspecs", "ls-tree", "-z", tree, "--", path)
|
|
29
|
+
if not raw:
|
|
30
|
+
return None
|
|
31
|
+
entries = raw.split(b"\0")
|
|
32
|
+
header, separator, name = entries[0].partition(b"\t")
|
|
33
|
+
fields = header.split()
|
|
34
|
+
if (
|
|
35
|
+
len(entries) != 2
|
|
36
|
+
or entries[-1] != b""
|
|
37
|
+
or not separator
|
|
38
|
+
or name != path.encode("utf-8")
|
|
39
|
+
or len(fields) != 3
|
|
40
|
+
or fields[0] not in (b"100644", b"100755")
|
|
41
|
+
or fields[1] != b"blob"
|
|
42
|
+
):
|
|
43
|
+
raise ChangeScopeError(
|
|
44
|
+
f"Documentation metadata requires a regular file: {path}"
|
|
45
|
+
)
|
|
46
|
+
return git_output(root, "cat-file", "blob", fields[2].decode("ascii"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _check_readme_reference(root: Path, tree: str) -> None:
|
|
50
|
+
content = _regular_blob(root, tree, "pyproject.toml")
|
|
51
|
+
if content is None:
|
|
52
|
+
return
|
|
53
|
+
try:
|
|
54
|
+
document = tomllib.loads(content.decode("utf-8"))
|
|
55
|
+
except (UnicodeError, tomllib.TOMLDecodeError) as error:
|
|
56
|
+
raise ChangeScopeError(
|
|
57
|
+
f"Cannot read candidate documentation metadata: {error}"
|
|
58
|
+
) from error
|
|
59
|
+
project = document.get("project", {})
|
|
60
|
+
if not isinstance(project, dict):
|
|
61
|
+
raise ChangeScopeError("Candidate project metadata must be a table")
|
|
62
|
+
readme: object = project.get("readme")
|
|
63
|
+
if readme is None:
|
|
64
|
+
return
|
|
65
|
+
if isinstance(readme, dict):
|
|
66
|
+
if set(readme) - {"file", "text", "content-type"}:
|
|
67
|
+
raise ChangeScopeError("Unsupported project.readme table fields")
|
|
68
|
+
if ("file" in readme) == ("text" in readme):
|
|
69
|
+
raise ChangeScopeError(
|
|
70
|
+
"project.readme requires exactly one of file or text"
|
|
71
|
+
)
|
|
72
|
+
content_type = readme.get("content-type")
|
|
73
|
+
if not isinstance(content_type, str) or not content_type.strip():
|
|
74
|
+
raise ChangeScopeError("project.readme table requires a content-type")
|
|
75
|
+
if "text" in readme:
|
|
76
|
+
if not isinstance(readme["text"], str):
|
|
77
|
+
raise ChangeScopeError("project.readme text must be a string")
|
|
78
|
+
return
|
|
79
|
+
readme = readme["file"]
|
|
80
|
+
if not isinstance(readme, str) or not readme:
|
|
81
|
+
raise ChangeScopeError("project.readme file must be a nonempty relative path")
|
|
82
|
+
path = posixpath.normpath(readme)
|
|
83
|
+
if path in (".", "..") or path.startswith(("/", "../")):
|
|
84
|
+
raise ChangeScopeError(
|
|
85
|
+
"project.readme file must stay inside the candidate tree"
|
|
86
|
+
)
|
|
87
|
+
if _regular_blob(root, tree, path) is None:
|
|
88
|
+
raise ChangeScopeError(f"Missing project.readme file in candidate: {readme}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _export_documents(root: Path, tree: str, destination: Path) -> None:
|
|
92
|
+
config_found = False
|
|
93
|
+
directories: dict[tuple[int, int], tuple[str, ...]] = {}
|
|
94
|
+
for entry in git_output(root, "ls-tree", "-r", "-z", tree).split(b"\0"):
|
|
95
|
+
if not entry:
|
|
96
|
+
continue
|
|
97
|
+
header, separator, raw_path = entry.partition(b"\t")
|
|
98
|
+
if not separator:
|
|
99
|
+
raise ChangeScopeError("Malformed Git tree entry")
|
|
100
|
+
fields = header.split()
|
|
101
|
+
if len(fields) != 3:
|
|
102
|
+
raise ChangeScopeError("Malformed Git tree entry header")
|
|
103
|
+
mode, kind, object_id = fields
|
|
104
|
+
path = os.fsdecode(raw_path)
|
|
105
|
+
if path != _CONFIG and not path.endswith(".md"):
|
|
106
|
+
continue
|
|
107
|
+
if mode not in (b"100644", b"100755") or kind != b"blob":
|
|
108
|
+
if path == _CONFIG:
|
|
109
|
+
raise ChangeScopeError("Markdown configuration must be a regular file")
|
|
110
|
+
continue
|
|
111
|
+
parts = PurePosixPath(path).parts
|
|
112
|
+
if not parts or PurePosixPath(path).is_absolute() or ".." in parts:
|
|
113
|
+
raise ChangeScopeError("Unsafe path in documentation tree")
|
|
114
|
+
output = destination.joinpath(*parts)
|
|
115
|
+
try:
|
|
116
|
+
parent = destination
|
|
117
|
+
for depth, component in enumerate(parts[:-1], start=1):
|
|
118
|
+
parent /= component
|
|
119
|
+
parent.mkdir(exist_ok=True)
|
|
120
|
+
metadata = parent.stat()
|
|
121
|
+
identity = (metadata.st_dev, metadata.st_ino)
|
|
122
|
+
previous = directories.setdefault(identity, parts[:depth])
|
|
123
|
+
if previous != parts[:depth]:
|
|
124
|
+
raise ChangeScopeError(
|
|
125
|
+
f"Colliding documentation directories: {path!r}"
|
|
126
|
+
)
|
|
127
|
+
with output.open("xb") as stream:
|
|
128
|
+
stream.write(
|
|
129
|
+
git_output(root, "cat-file", "blob", object_id.decode("ascii"))
|
|
130
|
+
)
|
|
131
|
+
except (FileExistsError, IsADirectoryError, NotADirectoryError) as error:
|
|
132
|
+
raise ChangeScopeError(
|
|
133
|
+
f"Colliding documentation paths on this filesystem: {path!r}"
|
|
134
|
+
) from error
|
|
135
|
+
config_found |= path == _CONFIG
|
|
136
|
+
if not config_found:
|
|
137
|
+
raise ChangeScopeError(f"Candidate has no regular {_CONFIG}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main() -> int:
|
|
141
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
142
|
+
add_target_arguments(parser)
|
|
143
|
+
arguments = parser.parse_args()
|
|
144
|
+
root = Path.cwd()
|
|
145
|
+
candidate = capture_candidate(root, arguments.candidate)
|
|
146
|
+
base = resolve_tree(root, arguments.base)
|
|
147
|
+
if classify_changes(base, candidate, root) != "documentation":
|
|
148
|
+
raise ChangeScopeError("Candidate is not a documentation-only change")
|
|
149
|
+
git_output(root, "diff", "--check", "--no-ext-diff", base, candidate, "--")
|
|
150
|
+
_check_readme_reference(root, candidate)
|
|
151
|
+
tool_name = "markdownlint-cli2.cmd" if os.name == "nt" else "markdownlint-cli2"
|
|
152
|
+
executable = root / "node_modules" / ".bin" / tool_name
|
|
153
|
+
if not executable.is_file() or not os.access(executable, os.X_OK):
|
|
154
|
+
raise ChangeScopeError(f"Missing repository-local Markdown tool: {executable}")
|
|
155
|
+
with TemporaryDirectory(prefix="h2h-docs-check-") as directory:
|
|
156
|
+
snapshot = Path(directory)
|
|
157
|
+
_export_documents(root, candidate, snapshot)
|
|
158
|
+
try:
|
|
159
|
+
subprocess.run((str(executable),), cwd=snapshot, check=True)
|
|
160
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
161
|
+
raise ChangeScopeError(f"Markdown check failed: {error}") from error
|
|
162
|
+
if capture_candidate(root, arguments.candidate) != candidate:
|
|
163
|
+
raise ChangeScopeError("Candidate changed while checking documentation")
|
|
164
|
+
if resolve_tree(root, arguments.base) != base:
|
|
165
|
+
raise ChangeScopeError("Base changed while checking documentation")
|
|
166
|
+
print(f"Documentation checks passed for tree {candidate}")
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
try:
|
|
172
|
+
raise SystemExit(main())
|
|
173
|
+
except (ChangeScopeError, OSError, UnicodeError) as error:
|
|
174
|
+
print(f"check-docs: {error}", file=sys.stderr)
|
|
175
|
+
raise SystemExit(1) from error
|