Repository navigation
Expand file tree
/
Copy pathbuild_resources.py
More file actions
113 lines (90 loc) · 4.84 KB
/
Copy pathbuild_resources.py
File metadata and controls
113 lines (90 loc) · 4.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
"""Collect and verify defaults in release artifacts; never bundle user history."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import tempfile
MANIFEST = "default-resources.json"
# Courses are optional: lessons ship via git course packs (details.json),
# not the app bundle. lang/keyboards stay required.
GROUPS = (("courses", "*.txt", False), ("lang", "*.json", True), ("keyboards", "*.json", True))
def inventory(project: Path) -> dict[str, str]:
project = project.resolve()
files = {}
for folder, pattern, required in GROUPS:
sources = sorted(path for path in (project / folder).glob(pattern) if path.is_file())
if not sources:
if required:
raise ValueError(f"No default {folder}/{pattern} files in {project}. Build from the complete source folder.")
continue
for source in sources:
files[source.relative_to(project).as_posix()] = hashlib.sha256(source.read_bytes()).hexdigest()
return files
def pyinstaller_data(project: Path) -> list[tuple[str, str]]:
project = project.resolve()
files = inventory(project)
generated = project / "build-resources" / MANIFEST
generated.parent.mkdir(parents=True, exist_ok=True)
generated.write_text(json.dumps({"version": 1, "files": files}, indent=2), encoding="utf-8")
data = [(str(project / name), str(Path(name).parent)) for name in files]
data.append((str(generated), "."))
print(f"Bundling {len(files)} default resource files (courses come from repos, not the bundle).")
return data
def _check_manifest(data: bytes, expected: dict[str, str]) -> None:
manifest = json.loads(data.decode("utf-8"))
if not isinstance(manifest, dict) or manifest.get("version") != 1 or manifest.get("files") != expected:
raise ValueError("Bundled default-resource manifest differs from the source files; rebuild the app.")
def verify_reader(reader, expected: dict[str, str]) -> None:
# PyInstaller archive separators depend on the target platform.
names = {name.replace("\\", "/"): name for name in reader.toc}
for name in (MANIFEST, *expected):
if name not in names:
raise ValueError(f"Release artifact is missing bundled file: {name}")
_check_manifest(reader.extract(names[MANIFEST]), expected)
for name, digest in expected.items():
if hashlib.sha256(reader.extract(names[name])).hexdigest() != digest:
raise ValueError(f"Release artifact contains incorrect default file: {name}")
def verify_executable(executable: Path, expected: dict[str, str]) -> None:
from PyInstaller.archive.readers import CArchiveReader
verify_reader(CArchiveReader(str(executable)), expected)
def verify_installer(installer: Path, expected: dict[str, str]) -> None:
from PyInstaller.archive.readers import CArchiveReader
reader = CArchiveReader(str(installer))
names = {name.replace("\\", "/"): name for name in reader.toc}
name = names.get("payload/bit-typing.exe")
if name is None:
raise ValueError("Windows installer is missing payload/bit-typing.exe.")
# Validate the executable inside the installer, not a loose file next to it.
with tempfile.TemporaryDirectory(prefix="bit-typing-payload-check-") as directory:
app = Path(directory) / "bit-typing.exe"
app.write_bytes(reader.extract(name))
verify_executable(app, expected)
def verify_app(app: Path, expected: dict[str, str]) -> None:
resources = app / "Contents" / "Resources"
_check_manifest((resources / MANIFEST).read_bytes(), expected)
for name, digest in expected.items():
path = resources / name
if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
raise ValueError(f"macOS app is missing or contains incorrect default file: {name}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("check-source", "verify-executable", "verify-installer", "verify-app"))
parser.add_argument("artifact", nargs="?", type=Path)
parser.add_argument("--project", type=Path, default=Path(__file__).resolve().parent)
args = parser.parse_args()
if args.mode != "check-source" and args.artifact is None:
parser.error("artifact path required")
try:
expected = inventory(args.project)
if args.mode == "verify-executable":
verify_executable(args.artifact, expected)
elif args.mode == "verify-installer":
verify_installer(args.artifact, expected)
elif args.mode == "verify-app":
verify_app(args.artifact, expected)
except (OSError, ValueError) as error:
parser.exit(1, f"Resource check failed: {error}\n")
print(f"Verified {len(expected)} default resource files ({args.mode}).")
if __name__ == "__main__":
main()