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.
@@ -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