-
Notifications
You must be signed in to change notification settings - Fork 1
/
build.py
172 lines (125 loc) · 5.57 KB
/
build.py
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
"""Building script.
Build application executable for Win32 in a virtual environment
and pack it with the INI file in a ZIP file for distribution.
"""
from collections.abc import Sequence
from io import TextIOWrapper
import os
from pathlib import Path
from subprocess import CalledProcessError, CompletedProcess, run
import sys
from typing import TextIO
from zipfile import ZIP_DEFLATED, ZipFile
from sacamantecas import Constants
from version import SEMVER
UTF8 = Constants.UTF8
APP_PATH = Constants.APP_PATH
VENV_PATH = APP_PATH.parent / '.venv'
BUILD_PATH = APP_PATH.parent / 'build'
PYINSTALLER = VENV_PATH / 'Scripts' / 'pyinstaller.exe'
FROZEN_EXE_PATH = (BUILD_PATH / APP_PATH.name).with_suffix('.exe')
PACKAGE_PATH = APP_PATH.with_stem(f'{APP_PATH.stem}_v{SEMVER.split('+')[0]}').with_suffix('.zip')
INIFILE_PATH = Constants.INIFILE_PATH
REQUIREMENTS_FILE = Path('requirements.txt')
ERROR_MARKER = '\n*** '
ERROR_HEADER = 'Error, '
PROGRESS_MARKER = ' ▶ '
# Reconfigure standard output streams so they use UTF-8 encoding, no matter
# if they are redirected to a file when running the program from a shell.
if sys.stdout and isinstance(sys.stdout, TextIOWrapper):
sys.stdout.reconfigure(encoding=Constants.UTF8)
if sys.stderr and isinstance(sys.stderr, TextIOWrapper):
sys.stderr.reconfigure(encoding=Constants.UTF8)
def pretty_print(message: str, *, marker: str = '', header: str = '', stream: TextIO = sys.stdout) -> None:
"""Pretty-print message to stream, with a final newline.
The first line contains the marker and header, if any, and the rest of lines
are indented according to the length of the marker so they are aligned with
the header.
The stream is finally flushed to ensure the message is printed.
By default, marker and header are empty and the stream is sys.stdout.
"""
marker_len = len([char for char in marker if char.isprintable()])
lines = message.splitlines() if message else ['']
lines[0] = f'{marker}{header}{lines[0]}'
lines[1:] = [f'\n{' ' * marker_len}{line}' for line in lines[1:]]
lines[-1] += '\n'
stream.writelines(lines)
stream.flush()
def error(message: str) -> None:
"""Pretty-print error message to sys.stderr."""
pretty_print(message, marker=ERROR_MARKER, header=ERROR_HEADER, stream=sys.stderr)
def progress(message: str) -> None:
"""Pretty-print progress message to sys.stdout."""
pretty_print(message, marker=PROGRESS_MARKER)
def run_command(command: Sequence[str]) -> CompletedProcess[str]:
"""Run command, capturing the output."""
try:
return run(command, check=True, capture_output=True, encoding=UTF8, text=True) # noqa: S603
except FileNotFoundError as exc:
raise CalledProcessError(0, command, None, f"Command '{command[0]}' not found.\n") from exc
def is_venv_ready() -> bool:
"""Check if virtual environment is active and functional."""
progress(f'Checking virtual environment at {VENV_PATH}')
# If no virtual environment exists, try to use global packages.
if not VENV_PATH.exists():
return True
# But if it exists, it has to be active.
if os.environ['VIRTUAL_ENV'].lower() != str(VENV_PATH).lower():
error('wrong or missing VIRTUAL_ENV environment variable.')
return False
if sys.prefix == sys.base_prefix:
error('virtual environment is not active.')
return False
return True
def are_required_packages_installed() -> bool:
"""Check installed packages to ensure they fit requirements.txt contents."""
progress('Checking that required packages are installed')
pip_list = ['pip', 'list', '--local', '--format=freeze', '--not-required', '--exclude=pip']
installed_packages = {package.split('==')[0] for package in run_command(pip_list).stdout.splitlines()}
with REQUIREMENTS_FILE.open(encoding='utf-8') as requirements:
required_packages = [line for line in requirements.readlines() if not line.startswith('#')]
required_packages = {package.split('>=')[0] for package in required_packages}
if diff := required_packages - installed_packages:
diff = '\n'.join(diff)
error(f'missing packages:\n{diff}\n')
return False
return True
def build_frozen_executable() -> bool:
"""Build frozen executable."""
progress('Building frozen executable')
if FROZEN_EXE_PATH.exists():
FROZEN_EXE_PATH.unlink()
cmd = [str(PYINSTALLER)]
cmd.append('--log-level=WARN')
cmd.extend([f'--workpath={BUILD_PATH}', f'--specpath={BUILD_PATH}', f'--distpath={BUILD_PATH}'])
cmd.extend(['--onefile', str(APP_PATH)])
try:
run_command(cmd)
except CalledProcessError as exc:
error(f'could not create frozen executable.\n{exc.stderr}')
return False
return True
def build_package() -> None:
"""Build distributable package."""
progress(f'Building distributable package {PACKAGE_PATH}.')
with ZipFile(PACKAGE_PATH, 'w', compression=ZIP_DEFLATED, compresslevel=9) as bundle:
bundle.write(FROZEN_EXE_PATH, FROZEN_EXE_PATH.name)
bundle.write(INIFILE_PATH, INIFILE_PATH.name)
def main() -> int:
"""."""
pretty_print(f'Building {APP_PATH.stem} {SEMVER}')
if not is_venv_ready():
return 1
if not are_required_packages_installed():
return 1
# The virtual environment is guaranteed to work from this point on.
if not build_frozen_executable():
return 1
build_package()
pretty_print('\nApplication built successfully!')
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(1)