-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathbuild.py
More file actions
125 lines (102 loc) · 3.9 KB
/
Copy pathbuild.py
File metadata and controls
125 lines (102 loc) · 3.9 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
114
115
116
117
118
119
120
121
122
123
124
125
#!/usr/bin/env python3
"""Build a native, single-file ``wild`` executable with PyInstaller.
This builder deliberately never supplies PyInstaller with ``--add-data``. In
particular, a local ``.env`` is rejected before the build starts so credentials
cannot accidentally become part of a release executable.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Sequence
ROOT = Path(__file__).resolve().parent
ENTRYPOINT = ROOT / "diffgraph" / "cli.py"
FORBIDDEN_BUNDLE_FILE_NAMES = frozenset({".env"})
def find_forbidden_files(root: Path) -> list[Path]:
"""Return credential-like dotfiles that must not be present for a build."""
return sorted(
path
for path in root.rglob("*")
if path.is_file()
and path.name in FORBIDDEN_BUNDLE_FILE_NAMES
and ".git" not in path.parts
and ".venv" not in path.parts
)
def assert_release_workspace_safe(root: Path) -> None:
"""Fail before PyInstaller runs if a local credential file is present."""
forbidden_files = find_forbidden_files(root)
if forbidden_files:
rendered_paths = ", ".join(str(path.relative_to(root)) for path in forbidden_files)
raise SystemExit(
"refusing to build from a workspace containing forbidden file(s): "
f"{rendered_paths}. Release builds never package .env files; "
"use process environment variables instead."
)
def build_command(*, output_dir: Path, work_dir: Path, spec_dir: Path, name: str) -> list[str]:
"""Return the intentionally data-free native PyInstaller invocation."""
return [
sys.executable,
"-m",
"PyInstaller",
"--noconfirm",
"--clean",
"--onefile",
"--console",
"--name",
name,
"--distpath",
str(output_dir),
"--workpath",
str(work_dir),
"--specpath",
str(spec_dir),
str(ENTRYPOINT),
]
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--name", default="wild", help="name of the generated executable")
parser.add_argument(
"--output-dir",
type=Path,
default=ROOT / "dist",
help="directory that receives the executable (default: %(default)s)",
)
return parser.parse_args(argv)
def assert_output_dir_safe(output_dir: Path) -> None:
"""Reject an output directory that would remove the checkout during cleanup."""
if ROOT.is_relative_to(output_dir):
raise SystemExit(
"refusing to use an output directory that is the repository root or an "
"ancestor of it"
)
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
output_dir = args.output_dir.resolve()
work_dir = ROOT / "build" / "pyinstaller"
spec_dir = ROOT / "build" / "spec"
assert_output_dir_safe(output_dir)
assert_release_workspace_safe(ROOT)
if not ENTRYPOINT.is_file():
raise SystemExit(f"build entrypoint is missing: {ENTRYPOINT}")
for directory in (output_dir, work_dir, spec_dir):
shutil.rmtree(directory, ignore_errors=True)
try:
import PyInstaller # noqa: F401
except ImportError as error:
raise SystemExit("PyInstaller is not installed; install requirements.txt first") from error
command = build_command(
output_dir=output_dir,
work_dir=work_dir,
spec_dir=spec_dir,
name=args.name,
)
subprocess.run(command, cwd=ROOT, check=True)
executable = output_dir / (f"{args.name}.exe" if sys.platform == "win32" else args.name)
if not executable.is_file():
raise SystemExit(f"build completed without expected executable: {executable}")
print(executable)
return 0
if __name__ == "__main__":
raise SystemExit(main())