maxson-build-utils 0.1.1__tar.gz
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.
- maxson_build_utils-0.1.1/PKG-INFO +32 -0
- maxson_build_utils-0.1.1/README.md +3 -0
- maxson_build_utils-0.1.1/pyproject.toml +77 -0
- maxson_build_utils-0.1.1/setup.cfg +4 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/VERSION +2 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/__init__.py +5 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/__main__.py +6 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/_version.py +22 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/build_executable.py +411 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/build_pyz.py +160 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/build_utils.py +25 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/cli.py +29 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/context.py +12 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/deb.py +21 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/helpers.py +23 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/linux_app_image.py +146 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/macos_dmg.py +107 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils/vendor.py +40 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils.egg-info/PKG-INFO +32 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils.egg-info/SOURCES.txt +22 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils.egg-info/dependency_links.txt +1 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils.egg-info/entry_points.txt +2 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils.egg-info/requires.txt +3 -0
- maxson_build_utils-0.1.1/src/maxson_build_utils.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maxson-build-utils
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Common tools for deploying packages to EXE, MSIX, DMG, DEB, PYZ, flatpak, and more. Templated approach to mkdocs.
|
|
5
|
+
Author-email: George Clayton Bennett <george.bennett@memphistn.gov>
|
|
6
|
+
Maintainer-email: George Clayton Bennett <george.bennett@memphistn.gov>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/city-of-memphis-wastewater/maxson-build-utils
|
|
9
|
+
Project-URL: Repository, https://github.com/city-of-memphis-wastewater/maxson-build-utils
|
|
10
|
+
Project-URL: Issues, https://github.com/city-of-memphis-wastewater/maxson-build-utils/issues
|
|
11
|
+
Project-URL: Changelog, https://raw.githubusercontent.com/city-of-memphis-wastewater/maxson-build-utils/main/docs/CHANGELOG.md
|
|
12
|
+
Keywords: build-tools,build
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Intended Audience :: Developers
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Classifier: Development Status :: 3 - Alpha
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: pyhabitat>=1.3.9
|
|
27
|
+
Requires-Dist: typer>=0.27.0
|
|
28
|
+
Requires-Dist: typer-helptree>=0.2.12
|
|
29
|
+
|
|
30
|
+
# maxson-build-utils
|
|
31
|
+
|
|
32
|
+
Centralized org tooling for PyInstaller, DMG, AppImage, DEB, PYZ, etc.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "maxson-build-utils"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Common tools for deploying packages to EXE, MSIX, DMG, DEB, PYZ, flatpak, and more. Templated approach to mkdocs."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pyhabitat>=1.3.9",
|
|
9
|
+
"typer>=0.27.0",
|
|
10
|
+
"typer-helptree>=0.2.12",
|
|
11
|
+
]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "George Clayton Bennett", email = "george.bennett@memphistn.gov" }
|
|
15
|
+
]
|
|
16
|
+
maintainers = [
|
|
17
|
+
{ name = "George Clayton Bennett", email = "george.bennett@memphistn.gov" }
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
classifiers=[
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Intended Audience :: Developers", # library and documentation
|
|
30
|
+
#"Intended Audience :: Other Audience", # Government/engineering docs
|
|
31
|
+
#"Topic :: Scientific/Engineering :: Information Analysis",
|
|
32
|
+
#"Environment :: Console",
|
|
33
|
+
#"Environment :: MacOS X",
|
|
34
|
+
#"Environment :: Win32 (MS Windows)",
|
|
35
|
+
"Typing :: Typed",
|
|
36
|
+
"Development Status :: 3 - Alpha",
|
|
37
|
+
#"Development Status :: 4 - Beta",
|
|
38
|
+
#"Development Status :: 5 - Production/Stable",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
keywords = [
|
|
42
|
+
"build-tools",
|
|
43
|
+
"build",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
maxson-build-utils = "maxson_build_utils.__main__:main"
|
|
48
|
+
|
|
49
|
+
#[project.optional-dependencies]
|
|
50
|
+
|
|
51
|
+
# add with `uv sync --group dev`, plus other extras, like `--extra pdfium`
|
|
52
|
+
[dependency-groups]
|
|
53
|
+
dev = [
|
|
54
|
+
"build>=1.3.0",
|
|
55
|
+
"pyinstaller>=6.17.0 ; platform_system == 'Linux' and platform_machine != 'aarch64'", # to avoid on termux
|
|
56
|
+
"shiv>=1.0.8",
|
|
57
|
+
"ruff>=0.7.0 ; platform_system == 'Linux' and platform_machine != 'aarch64'", # to avoid on termux
|
|
58
|
+
"pytest>=8.0.0",
|
|
59
|
+
"pytest-cov>=4.1.0",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[project.urls]
|
|
63
|
+
Homepage = "https://github.com/city-of-memphis-wastewater/maxson-build-utils"
|
|
64
|
+
Repository = "https://github.com/city-of-memphis-wastewater/maxson-build-utils"
|
|
65
|
+
Issues = "https://github.com/city-of-memphis-wastewater/maxson-build-utils/issues"
|
|
66
|
+
Changelog = "https://raw.githubusercontent.com/city-of-memphis-wastewater/maxson-build-utils/main/docs/CHANGELOG.md"
|
|
67
|
+
|
|
68
|
+
[build-system]
|
|
69
|
+
requires = ["setuptools>=64", "wheel"]
|
|
70
|
+
build-backend = "setuptools.build_meta"
|
|
71
|
+
|
|
72
|
+
[tool.uv.sources]
|
|
73
|
+
maxson-build-utils = { path = "src/maxson_build_utils" }
|
|
74
|
+
#pyhabitat = { path = "../pyhabitat/dist/pyhabitat-1.2.3-py3-none-any.whl" }
|
|
75
|
+
|
|
76
|
+
[tool.setuptools.dynamic]
|
|
77
|
+
version = {file = "src/maxson_build_utils/VERSION"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# src/maxson_build_utils/_version.py
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .context import APP_NAME
|
|
4
|
+
def get_version() -> str:
|
|
5
|
+
try:
|
|
6
|
+
version_file = Path(__file__).parent / "VERSION"
|
|
7
|
+
if version_file.exists():
|
|
8
|
+
return version_file.read_text(encoding="utf-8").strip()
|
|
9
|
+
except Exception:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
# Try metadata (Installed)
|
|
13
|
+
try:
|
|
14
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
15
|
+
return version(APP_NAME)
|
|
16
|
+
except (ImportError, PackageNotFoundError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
return "0.0.0-unknown"
|
|
21
|
+
|
|
22
|
+
__version__ = get_version()
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# maxson_build_utils/builder.py
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
maxson_build_utils/builder.py
|
|
6
|
+
Modular build execution orchestrator for PyInstaller, AppImage, and DMG workflows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
from enum import Enum
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
|
|
21
|
+
import pyhabitat
|
|
22
|
+
|
|
23
|
+
from .helpers import form_dynamic_name, PyinsMode, IconFileType
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DIST_DIR = Path("dist")
|
|
29
|
+
DIST_DIR_ONEFILE = DIST_DIR / PyinsMode.ONEFILE.value
|
|
30
|
+
DIST_DIR_ONEDIR = DIST_DIR / PyinsMode.ONEDIR.value
|
|
31
|
+
STANDARD_MACOS_APP_DIST_DIR = DIST_DIR
|
|
32
|
+
BUILD_DIR = Path("build/pyinstaller_work")
|
|
33
|
+
RC_TEMPLATE = Path("build_assets") / "version.rc.template"
|
|
34
|
+
RC_FILE = Path("build_assets") / "version.rc"
|
|
35
|
+
PROJECT_ROOT = Path.cwd()
|
|
36
|
+
HOOKS_DIR_ABS = PROJECT_ROOT / "pyinstaller_hooks"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_icon_filetype(icon_src: Path) -> IconFileType | None:
|
|
40
|
+
"""Resolves and validates the extension of a given icon path."""
|
|
41
|
+
suffix = icon_src.suffix.lower().removeprefix(".")
|
|
42
|
+
try:
|
|
43
|
+
return IconFileType(suffix)
|
|
44
|
+
except ValueError:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def form_dynamic_name(src_folder_name: str, version: str, mode: PyinsMode) -> str:
|
|
49
|
+
"""Forms a standard dynamic build descriptor string."""
|
|
50
|
+
return f"{src_folder_name}-{version}-{mode.value}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_cli_main_file(src_folder_name: str) -> Path:
|
|
54
|
+
"""Locates the entry point module inside the package source folder."""
|
|
55
|
+
return PROJECT_ROOT / "src" / src_folder_name / "__main__.py"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def generate_rc_file(package_version: str) -> None:
|
|
59
|
+
"""Generates the .rc file using the provided version string on Windows."""
|
|
60
|
+
if not pyhabitat.on_windows():
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if not RC_TEMPLATE.exists():
|
|
64
|
+
logger.warning("RC template not found at %s. Skipping version info.", RC_TEMPLATE)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
logger.info("Generated resource file %s for version %s", RC_FILE, package_version)
|
|
68
|
+
RC_FILE.write_text("// Placeholder content for versioning", encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def setup_dirs() -> None:
|
|
72
|
+
"""Ensures intermediate and output distribution directories exist."""
|
|
73
|
+
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
DIST_DIR.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
DIST_DIR_ONEFILE.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
DIST_DIR_ONEDIR.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def determine_file_extension(is_windowed_build: bool) -> str:
|
|
80
|
+
"""Determines binary output extension based on target system and window flag."""
|
|
81
|
+
if pyhabitat.on_windows():
|
|
82
|
+
return ".exe"
|
|
83
|
+
elif pyhabitat.on_macos() and is_windowed_build:
|
|
84
|
+
return ".app"
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def clean_artifacts(exe_name: str, mode: PyinsMode, file_extension: str) -> None:
|
|
89
|
+
"""Cleans previous build outputs and temporary work directories."""
|
|
90
|
+
target = (
|
|
91
|
+
DIST_DIR_ONEDIR / exe_name
|
|
92
|
+
if mode == PyinsMode.ONEDIR
|
|
93
|
+
else DIST_DIR_ONEFILE / f"{exe_name}{file_extension}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if target.exists():
|
|
97
|
+
logger.info("Removing old build artifact: %s", target.resolve())
|
|
98
|
+
if target.is_dir():
|
|
99
|
+
shutil.rmtree(target)
|
|
100
|
+
else:
|
|
101
|
+
target.unlink()
|
|
102
|
+
|
|
103
|
+
if BUILD_DIR.exists():
|
|
104
|
+
logger.info("Removing build work folder: %s", BUILD_DIR.resolve())
|
|
105
|
+
shutil.rmtree(BUILD_DIR)
|
|
106
|
+
|
|
107
|
+
if pyhabitat.on_windows() and RC_FILE.exists():
|
|
108
|
+
RC_FILE.unlink()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def determine_app_path_and_dist_path(
|
|
112
|
+
dynamic_exe_name: str, mode: PyinsMode, is_windowed_build: bool
|
|
113
|
+
) -> tuple[str, Path, Path, str]:
|
|
114
|
+
"""Calculates distribution targets and final artifact file paths."""
|
|
115
|
+
ext = determine_file_extension(is_windowed_build)
|
|
116
|
+
app_filename = f"{dynamic_exe_name}{ext}"
|
|
117
|
+
|
|
118
|
+
if mode == PyinsMode.ONEFILE:
|
|
119
|
+
dist_path = DIST_DIR_ONEFILE
|
|
120
|
+
app_path = DIST_DIR_ONEFILE / app_filename
|
|
121
|
+
else:
|
|
122
|
+
if pyhabitat.on_macos():
|
|
123
|
+
dist_path = STANDARD_MACOS_APP_DIST_DIR
|
|
124
|
+
app_path = STANDARD_MACOS_APP_DIST_DIR / app_filename
|
|
125
|
+
else:
|
|
126
|
+
dist_path = DIST_DIR_ONEDIR
|
|
127
|
+
app_path = DIST_DIR_ONEDIR / dynamic_exe_name / app_filename
|
|
128
|
+
|
|
129
|
+
dist_path.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
logger.info("Executable target path: %s", app_path.resolve())
|
|
131
|
+
return app_filename, dist_path, app_path, ext
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_linux_appimage(
|
|
135
|
+
app_dir_path: Path,
|
|
136
|
+
dynamic_exe_name: str,
|
|
137
|
+
app_name_pretty: str,
|
|
138
|
+
icon_src: Path,
|
|
139
|
+
) -> Path:
|
|
140
|
+
"""Packages a PyInstaller ONEDIR bundle into a standalone Linux AppImage."""
|
|
141
|
+
logger.info("Executing build_linux_appimage()")
|
|
142
|
+
logger.info("Source AppDir components from: %s", app_dir_path)
|
|
143
|
+
|
|
144
|
+
if shutil.which("appimagetool") is None:
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
"appimagetool is not installed. Please download it or install via your package manager."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
upload_dir = DIST_DIR / "upload"
|
|
150
|
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
appimage_output_path = upload_dir / f"{dynamic_exe_name}-x86_64.AppImage"
|
|
152
|
+
|
|
153
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
154
|
+
tmp_dir = Path(tmp)
|
|
155
|
+
staged_appdir = tmp_dir / f"{dynamic_exe_name}.AppDir"
|
|
156
|
+
staged_appdir.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
|
|
158
|
+
usr_bin = staged_appdir / "usr" / "bin"
|
|
159
|
+
usr_bin.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
shutil.copytree(app_dir_path, usr_bin / dynamic_exe_name)
|
|
161
|
+
|
|
162
|
+
apprun_path = staged_appdir / "AppRun"
|
|
163
|
+
apprun_content = f"""#!/bin/sh
|
|
164
|
+
HERE="$(dirname "$(readlink -f "${{0}}")")"
|
|
165
|
+
EXEC="${{HERE}}/usr/bin/{dynamic_exe_name}/{dynamic_exe_name}"
|
|
166
|
+
exec "${{EXEC}}" "$@"
|
|
167
|
+
"""
|
|
168
|
+
apprun_path.write_text(apprun_content, encoding="utf-8")
|
|
169
|
+
apprun_path.chmod(0o755)
|
|
170
|
+
|
|
171
|
+
desktop_path = staged_appdir / f"{dynamic_exe_name}.desktop"
|
|
172
|
+
desktop_content = f"""[Desktop Entry]
|
|
173
|
+
Type=Application
|
|
174
|
+
Name={app_name_pretty}
|
|
175
|
+
Exec=AppRun
|
|
176
|
+
Icon={dynamic_exe_name}
|
|
177
|
+
Categories=Utility;
|
|
178
|
+
Terminal=true
|
|
179
|
+
"""
|
|
180
|
+
desktop_path.write_text(desktop_content, encoding="utf-8")
|
|
181
|
+
|
|
182
|
+
icon_filetype = resolve_icon_filetype(icon_src)
|
|
183
|
+
if icon_filetype is None:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"Unsupported icon type: '{icon_src.suffix}'. "
|
|
186
|
+
f"Supported types: {[x.value for x in IconFileType]}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
icon_dst = staged_appdir / f"{dynamic_exe_name}.{icon_filetype.value}"
|
|
190
|
+
|
|
191
|
+
logger.info("Staging Linux AppImage icon: %s -> %s", icon_src.name, icon_dst.name)
|
|
192
|
+
if icon_src.exists():
|
|
193
|
+
shutil.copy2(icon_src, icon_dst)
|
|
194
|
+
else:
|
|
195
|
+
raise FileNotFoundError(
|
|
196
|
+
f"Critical asset missing! Could not locate icon at: {icon_src.resolve()}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
logger.info("Compiling AppImage to %s...", appimage_output_path)
|
|
200
|
+
subprocess.run(
|
|
201
|
+
[
|
|
202
|
+
"appimagetool",
|
|
203
|
+
str(staged_appdir.resolve()),
|
|
204
|
+
str(appimage_output_path.resolve()),
|
|
205
|
+
],
|
|
206
|
+
check=True,
|
|
207
|
+
env=os.environ.copy(),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return appimage_output_path
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def post_process_linux_build(
|
|
214
|
+
app_path: Path,
|
|
215
|
+
dynamic_exe_name: str,
|
|
216
|
+
app_name_pretty: str,
|
|
217
|
+
icon_src: Path,
|
|
218
|
+
mode: PyinsMode,
|
|
219
|
+
) -> Path | None:
|
|
220
|
+
"""Handles downstream staging tasks on Linux platforms (skips Termux)."""
|
|
221
|
+
is_linux = getattr(pyhabitat, "on_linux", lambda: sys.platform.startswith("linux"))()
|
|
222
|
+
is_termux = getattr(pyhabitat, "on_termux", lambda: False)()
|
|
223
|
+
|
|
224
|
+
if is_linux and not is_termux and mode == PyinsMode.ONEDIR:
|
|
225
|
+
bundle_dir = app_path.parent
|
|
226
|
+
return build_linux_appimage(
|
|
227
|
+
app_dir_path=bundle_dir,
|
|
228
|
+
dynamic_exe_name=dynamic_exe_name,
|
|
229
|
+
app_name_pretty=app_name_pretty,
|
|
230
|
+
icon_src=icon_src,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def construct_pyinstaller_command(
|
|
237
|
+
dynamic_exe_name: str,
|
|
238
|
+
dist_path: Path,
|
|
239
|
+
mode: PyinsMode,
|
|
240
|
+
main_script_path: Path,
|
|
241
|
+
is_windowed_build: bool,
|
|
242
|
+
src_folder_name: str,
|
|
243
|
+
icon_ico_path: Path | None = None,
|
|
244
|
+
icon_icns_path: Path | None = None,
|
|
245
|
+
collect_data_pkgs: list[str] | set[str] | None = None,
|
|
246
|
+
collect_binary_pkgs: list[str] | set[str] | None = None,
|
|
247
|
+
) -> list[str]:
|
|
248
|
+
"""Constructs the PyInstaller CLI argument array."""
|
|
249
|
+
base_command = [
|
|
250
|
+
sys.executable,
|
|
251
|
+
"-m",
|
|
252
|
+
"PyInstaller",
|
|
253
|
+
"--noconfirm",
|
|
254
|
+
"--clean",
|
|
255
|
+
f"--name={dynamic_exe_name}",
|
|
256
|
+
f'--paths={PROJECT_ROOT / "src"}',
|
|
257
|
+
f"--distpath={dist_path}",
|
|
258
|
+
f'--workpath={BUILD_DIR / "work"}',
|
|
259
|
+
f"--specpath={BUILD_DIR}",
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
# Explicit data directory check
|
|
263
|
+
data_dir = PROJECT_ROOT / "src" / src_folder_name / "data"
|
|
264
|
+
if data_dir.exists():
|
|
265
|
+
base_command.append(f"--add-data={data_dir}{os.path.pathsep}{src_folder_name}/data")
|
|
266
|
+
|
|
267
|
+
# Safely attach hooks directory only if present in target project
|
|
268
|
+
if HOOKS_DIR_ABS.exists() and HOOKS_DIR_ABS.is_dir():
|
|
269
|
+
base_command.append(f"--additional-hooks-dir={HOOKS_DIR_ABS}")
|
|
270
|
+
|
|
271
|
+
# Process dynamic package collection flags
|
|
272
|
+
for pkg in collect_data_pkgs or []:
|
|
273
|
+
base_command.append(f"--collect-data={pkg}")
|
|
274
|
+
|
|
275
|
+
for pkg in collect_binary_pkgs or []:
|
|
276
|
+
base_command.append(f"--collect-binaries={pkg}")
|
|
277
|
+
|
|
278
|
+
base_command.append(f"--{mode.value}")
|
|
279
|
+
|
|
280
|
+
if is_windowed_build:
|
|
281
|
+
base_command.append("--windowed")
|
|
282
|
+
else:
|
|
283
|
+
logger.info("Building without --windowed flag (favoring CLI usage).")
|
|
284
|
+
|
|
285
|
+
if pyhabitat.on_windows():
|
|
286
|
+
if RC_FILE.exists():
|
|
287
|
+
base_command.append(f"--version-file={RC_FILE.resolve()}")
|
|
288
|
+
if icon_ico_path and icon_ico_path.exists():
|
|
289
|
+
base_command.append(f"--icon={icon_ico_path.resolve()}")
|
|
290
|
+
|
|
291
|
+
if pyhabitat.on_macos() and icon_icns_path and icon_icns_path.exists():
|
|
292
|
+
base_command.append(f"--icon={icon_icns_path.resolve()}")
|
|
293
|
+
|
|
294
|
+
base_command.append(str(main_script_path.resolve()))
|
|
295
|
+
return base_command
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def run_pyinstaller(
|
|
299
|
+
dynamic_exe_name: str,
|
|
300
|
+
main_script_path: Path,
|
|
301
|
+
src_folder_name: str,
|
|
302
|
+
mode: PyinsMode = PyinsMode.ONEDIR,
|
|
303
|
+
is_windowed_build: bool = True,
|
|
304
|
+
icon_ico_path: Path | None = None,
|
|
305
|
+
icon_icns_path: Path | None = None,
|
|
306
|
+
collect_data_pkgs: list[str] | set[str] | None = None,
|
|
307
|
+
collect_binary_pkgs: list[str] | set[str] | None = None,
|
|
308
|
+
) -> tuple[Path, str]:
|
|
309
|
+
"""Executes the PyInstaller command within a subshell."""
|
|
310
|
+
logger.info("--- %s Executable Builder ---", src_folder_name)
|
|
311
|
+
app_filename, dist_path, app_path, ext = determine_app_path_and_dist_path(
|
|
312
|
+
dynamic_exe_name, mode, is_windowed_build
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
clean_artifacts(exe_name=dynamic_exe_name, mode=mode, file_extension=ext)
|
|
316
|
+
setup_dirs()
|
|
317
|
+
|
|
318
|
+
full_command = construct_pyinstaller_command(
|
|
319
|
+
dynamic_exe_name=dynamic_exe_name,
|
|
320
|
+
dist_path=dist_path,
|
|
321
|
+
mode=mode,
|
|
322
|
+
main_script_path=main_script_path,
|
|
323
|
+
is_windowed_build=is_windowed_build,
|
|
324
|
+
src_folder_name=src_folder_name,
|
|
325
|
+
icon_ico_path=icon_ico_path,
|
|
326
|
+
icon_icns_path=icon_icns_path,
|
|
327
|
+
collect_data_pkgs=collect_data_pkgs,
|
|
328
|
+
collect_binary_pkgs=collect_binary_pkgs,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
logger.info("Executing command: %s", " ".join(full_command))
|
|
332
|
+
try:
|
|
333
|
+
subprocess.run(full_command, check=True, env=os.environ.copy())
|
|
334
|
+
except subprocess.CalledProcessError as e:
|
|
335
|
+
logger.error("PyInstaller failed with exit code %d", e.returncode)
|
|
336
|
+
raise SystemExit(e.returncode) from e
|
|
337
|
+
|
|
338
|
+
if pyhabitat.on_macos() and mode == PyinsMode.ONEDIR:
|
|
339
|
+
duplicate_cli_dir = DIST_DIR / dynamic_exe_name
|
|
340
|
+
if duplicate_cli_dir.exists() and duplicate_cli_dir.is_dir():
|
|
341
|
+
shutil.rmtree(duplicate_cli_dir)
|
|
342
|
+
|
|
343
|
+
return app_path.resolve(), app_filename
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def run_build_executable(
|
|
347
|
+
src_folder_name: str,
|
|
348
|
+
version: str,
|
|
349
|
+
app_name_pretty: str,
|
|
350
|
+
icon_png_path: Path | None = None,
|
|
351
|
+
icon_ico_path: Path | None = None,
|
|
352
|
+
icon_icns_path: Path | None = None,
|
|
353
|
+
collect_data_pkgs: list[str] | set[str] | None = None,
|
|
354
|
+
collect_binary_pkgs: list[str] | set[str] | None = None,
|
|
355
|
+
args_list: list[str] | None = None,
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Primary entry point for downstream project packaging scripts."""
|
|
358
|
+
parser = argparse.ArgumentParser(description=f"Build runner for {app_name_pretty}")
|
|
359
|
+
parser.add_argument(
|
|
360
|
+
"--mode",
|
|
361
|
+
type=PyinsMode,
|
|
362
|
+
choices=list(PyinsMode),
|
|
363
|
+
default=PyinsMode.ONEDIR,
|
|
364
|
+
help="PyInstaller build mode.",
|
|
365
|
+
)
|
|
366
|
+
args = parser.parse_args(args_list)
|
|
367
|
+
mode = args.mode
|
|
368
|
+
|
|
369
|
+
is_windowed_build = (
|
|
370
|
+
(pyhabitat.on_windows() or pyhabitat.on_macos())
|
|
371
|
+
and (mode == PyinsMode.ONEDIR)
|
|
372
|
+
and pyhabitat.tkinter_is_available()
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
if version == "0.0.0" or not version:
|
|
377
|
+
logger.error("FATAL: Invalid package version provided.")
|
|
378
|
+
sys.exit(1)
|
|
379
|
+
|
|
380
|
+
generate_rc_file(version)
|
|
381
|
+
executable_descriptor = form_dynamic_name(src_folder_name, version, mode)
|
|
382
|
+
cli_main_file = get_cli_main_file(src_folder_name)
|
|
383
|
+
|
|
384
|
+
app_path, app_filename = run_pyinstaller(
|
|
385
|
+
dynamic_exe_name=executable_descriptor,
|
|
386
|
+
main_script_path=cli_main_file,
|
|
387
|
+
src_folder_name=src_folder_name,
|
|
388
|
+
mode=mode,
|
|
389
|
+
is_windowed_build=is_windowed_build,
|
|
390
|
+
icon_ico_path=icon_ico_path,
|
|
391
|
+
icon_icns_path=icon_icns_path,
|
|
392
|
+
collect_data_pkgs=collect_data_pkgs,
|
|
393
|
+
collect_binary_pkgs=collect_binary_pkgs,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if icon_png_path and icon_png_path.exists():
|
|
397
|
+
appimage_path = post_process_linux_build(
|
|
398
|
+
app_path=app_path,
|
|
399
|
+
dynamic_exe_name=executable_descriptor,
|
|
400
|
+
app_name_pretty=app_name_pretty,
|
|
401
|
+
icon_src=icon_png_path,
|
|
402
|
+
mode=mode,
|
|
403
|
+
)
|
|
404
|
+
if appimage_path:
|
|
405
|
+
logger.info("AppImage successfully generated at: %s", appimage_path)
|
|
406
|
+
|
|
407
|
+
except SystemExit as e:
|
|
408
|
+
sys.exit(e.code)
|
|
409
|
+
except Exception as e:
|
|
410
|
+
logger.error("An unhandled error occurred during build: %s", e, exc_info=True)
|
|
411
|
+
sys.exit(1)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# maxson_build_utils/src/maxson_build_utils/build_pyz.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import site
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import pyhabitat
|
|
12
|
+
|
|
13
|
+
from .helpers import form_dynamic_name
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_command(cmd: list[str], check: bool = True, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
|
17
|
+
"""Run command with logging and error reporting."""
|
|
18
|
+
print(f"\n$ {' '.join(cmd)}")
|
|
19
|
+
final_env = env if env is not None else os.environ.copy()
|
|
20
|
+
|
|
21
|
+
result = subprocess.run(cmd, text=True, check=False, capture_output=True, env=final_env)
|
|
22
|
+
|
|
23
|
+
if result.stdout:
|
|
24
|
+
print(result.stdout.strip())
|
|
25
|
+
|
|
26
|
+
if result.returncode != 0:
|
|
27
|
+
if result.stderr:
|
|
28
|
+
print(f"--- COMMAND FAILED (Exit {result.returncode}) ---", file=sys.stderr)
|
|
29
|
+
print(result.stderr.strip(), file=sys.stderr)
|
|
30
|
+
|
|
31
|
+
if check:
|
|
32
|
+
raise subprocess.CalledProcessError(
|
|
33
|
+
result.returncode, cmd, output=result.stdout, stderr=result.stderr
|
|
34
|
+
)
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_latest_wheel(dist_dir: Path, src_folder_name: str, version: str) -> Path:
|
|
39
|
+
"""Finds the most recently built wheel file for the given package and version."""
|
|
40
|
+
wheels = sorted(
|
|
41
|
+
dist_dir.glob(f"{src_folder_name}-{version}*.whl"),
|
|
42
|
+
key=lambda f: f.stat().st_mtime,
|
|
43
|
+
reverse=True,
|
|
44
|
+
)
|
|
45
|
+
if not wheels:
|
|
46
|
+
raise FileNotFoundError(f"No wheel found for {src_folder_name} version {version} in {dist_dir}.")
|
|
47
|
+
return wheels[0]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ensure_dependencies_and_shiv() -> None:
|
|
51
|
+
"""Ensures environment dependencies and 'shiv' are installed and accessible."""
|
|
52
|
+
if os.environ.get("CI") == "true":
|
|
53
|
+
print("\nSkipping dependency check/install inside CI environment.")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
print("Syncing dev group dependencies via uv...")
|
|
57
|
+
run_command(["uv", "sync", "--group", "dev"])
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
run_command(["uv", "run", "shiv", "--version"], check=True)
|
|
61
|
+
except subprocess.CalledProcessError:
|
|
62
|
+
print("Installing 'shiv'...")
|
|
63
|
+
run_command(["uv", "add", "shiv"])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_windows_bat_launcher(pyz_filename: str, output_dir: Path) -> None:
|
|
67
|
+
"""Creates a Windows BAT file launcher for GUI execution when running on Windows."""
|
|
68
|
+
if os.name != "nt":
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
bat_filename = pyz_filename.replace(".pyz", "-gui.bat")
|
|
72
|
+
bat_path = output_dir / bat_filename
|
|
73
|
+
|
|
74
|
+
bat_content = f"""@echo off
|
|
75
|
+
rem Launch {pyz_filename} with the 'gui' command using system Python.
|
|
76
|
+
python "%~dp0{pyz_filename}" gui
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
bat_path.write_text(bat_content, encoding="utf-8")
|
|
80
|
+
print(f"Created Windows BAT launcher: {bat_path.name}")
|
|
81
|
+
except Exception as e:
|
|
82
|
+
print(f"WARNING: Failed to create BAT launcher: {e}", file=sys.stderr)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_build_pyz(
|
|
86
|
+
src_folder_name: str,
|
|
87
|
+
version: str,
|
|
88
|
+
entry_point: str,
|
|
89
|
+
dist_dir: Path = Path("dist") / "zipapp",
|
|
90
|
+
test_gui: bool = True,
|
|
91
|
+
) -> Path:
|
|
92
|
+
"""Builds a portable Python ZipApp (PYZ) using shiv and verifies the output artifact."""
|
|
93
|
+
if version == "0.0.0":
|
|
94
|
+
raise ValueError("Cannot proceed without a valid version string.")
|
|
95
|
+
|
|
96
|
+
dist_dir.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
|
|
98
|
+
# Configure isolated temporary root for Shiv internal caching
|
|
99
|
+
build_temp = Path(tempfile.gettempdir()) / "shiv_build"
|
|
100
|
+
if build_temp.exists():
|
|
101
|
+
shutil.rmtree(build_temp, ignore_errors=True)
|
|
102
|
+
build_temp.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
os.environ["SHIV_ROOT"] = str(build_temp)
|
|
104
|
+
|
|
105
|
+
# 1. Sync dependencies
|
|
106
|
+
ensure_dependencies_and_shiv()
|
|
107
|
+
|
|
108
|
+
# 2. Build Wheel into target zipapp directory
|
|
109
|
+
print("\nBuilding project wheel via uv build...")
|
|
110
|
+
custom_env = os.environ.copy()
|
|
111
|
+
if pyhabitat.on_termux():
|
|
112
|
+
termux_tmp = Path.home() / ".tmp"
|
|
113
|
+
termux_tmp.mkdir(exist_ok=True)
|
|
114
|
+
custom_env["TMPDIR"] = str(termux_tmp)
|
|
115
|
+
|
|
116
|
+
run_command(["uv", "build", "--wheel", "--out-dir", str(dist_dir)], env=custom_env)
|
|
117
|
+
|
|
118
|
+
# 3. Locate wheel and construct Shiv command
|
|
119
|
+
wheel_path = find_latest_wheel(dist_dir, src_folder_name, version)
|
|
120
|
+
dynamic_name = form_dynamic_name(src_folder_name, version, mode=None)
|
|
121
|
+
pyz_filename = f"{dynamic_name}-shiv.pyz"
|
|
122
|
+
interpreter = "python" if os.name == "nt" else "/usr/bin/env python3"
|
|
123
|
+
output_path = dist_dir / pyz_filename
|
|
124
|
+
|
|
125
|
+
if output_path.exists():
|
|
126
|
+
output_path.unlink()
|
|
127
|
+
|
|
128
|
+
cmd = [
|
|
129
|
+
"uv", "run", "shiv",
|
|
130
|
+
"-o", str(output_path),
|
|
131
|
+
"-p", interpreter,
|
|
132
|
+
"-e", entry_point,
|
|
133
|
+
"--compressed",
|
|
134
|
+
"--no-cache",
|
|
135
|
+
str(wheel_path),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
print(f"\nBuilding PYZ using shiv from Wheel: {pyz_filename}")
|
|
139
|
+
run_command(cmd, env=custom_env)
|
|
140
|
+
output_path.chmod(0o755)
|
|
141
|
+
|
|
142
|
+
# Cleanup staging wheel
|
|
143
|
+
try:
|
|
144
|
+
wheel_path.unlink()
|
|
145
|
+
print(f"Removed staging wheel: {wheel_path.name}")
|
|
146
|
+
except Exception as e:
|
|
147
|
+
print(f"Note: Could not delete temporary wheel: {e}")
|
|
148
|
+
|
|
149
|
+
create_windows_bat_launcher(pyz_filename, dist_dir)
|
|
150
|
+
|
|
151
|
+
# 4. Post-build verification
|
|
152
|
+
print("\nTesting generated Shiv artifact...")
|
|
153
|
+
run_command([sys.executable, str(output_path), "--help"], check=True)
|
|
154
|
+
|
|
155
|
+
if test_gui and pyhabitat.tkinter_is_available():
|
|
156
|
+
print(f"Testing GUI mode for {output_path.name}...")
|
|
157
|
+
run_command([sys.executable, str(output_path), "gui", "--auto-close", "1000"], check=True)
|
|
158
|
+
|
|
159
|
+
print(f"\nBuild complete! Portable PYZ: {output_path.resolve()}")
|
|
160
|
+
return output_path
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# maxson_build_utils/build_utils.py
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import pyhabitat
|
|
6
|
+
from enum import Enum
|
|
7
|
+
import sys
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
class PyinsMode(str, Enum):
|
|
11
|
+
"""onedir vs onefile"""
|
|
12
|
+
ONEDIR = "onedir"
|
|
13
|
+
ONEFILE = "onefile"
|
|
14
|
+
|
|
15
|
+
# --- Dynamic Naming Placeholder (Simplified version for this context) ---
|
|
16
|
+
def form_dynamic_name(pkg_name: str, version: str, mode: PyinsMode|None = None) -> str:
|
|
17
|
+
"""Creates a standardized binary name descriptor."""
|
|
18
|
+
|
|
19
|
+
os_tag = pyhabitat.SystemInfo().get_os_tag()
|
|
20
|
+
arch = pyhabitat.SystemInfo().get_arch()
|
|
21
|
+
py_ver = f"py{sys.version_info.major}{sys.version_info.minor}"
|
|
22
|
+
dynamic_exe_name = f"{pkg_name}-{version}-{py_ver}-{os_tag}-{arch}"
|
|
23
|
+
if mode == PyinsMode.ONEFILE:
|
|
24
|
+
dynamic_exe_name += f"-{PyinsMode.ONEFILE.value}"
|
|
25
|
+
return dynamic_exe_name
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from maxson_build_utils.deb import build_debian_package
|
|
4
|
+
from maxson_build_utils.vendor import vendor_wheels
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(help="Maxson build and packaging tools.")
|
|
7
|
+
|
|
8
|
+
@app.command(name="prepare-flatpak")
|
|
9
|
+
def prepare_flatpak(
|
|
10
|
+
dist_dir: Path = Path("dist"),
|
|
11
|
+
vendor_dir: Path = Path("vendor-wheels")
|
|
12
|
+
):
|
|
13
|
+
"""Build project wheel and vendor offline dependencies for Flatpak."""
|
|
14
|
+
vendor_wheels(dist_dir=dist_dir, vendor_dir=vendor_dir)
|
|
15
|
+
|
|
16
|
+
@app.command(name="build-deb")
|
|
17
|
+
def build_deb(
|
|
18
|
+
app_name: str = typer.Option("cellshift", "--app-name", help="Package application name"),
|
|
19
|
+
version: str = typer.Option("0.1.0", "--version", help="Version string"),
|
|
20
|
+
arch: str = typer.Option("amd64", "--arch", help="Target architecture")
|
|
21
|
+
):
|
|
22
|
+
"""Assemble and build a Debian .deb package."""
|
|
23
|
+
build_debian_package(app_name=app_name, version=version, arch=arch)
|
|
24
|
+
|
|
25
|
+
def main():
|
|
26
|
+
app()
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# src/maxson_build_utils/context.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
APP_NAME = "maxson-build-utils"
|
|
6
|
+
SRC_FOLDER_NAME = "maxson_build_utils"
|
|
7
|
+
APP_NAME_PRETTY = "maxson-build-utils"
|
|
8
|
+
APP_DIR = Path.home() / f".{APP_NAME}"
|
|
9
|
+
APP_DIR.mkdir(parents=True,exist_ok=True)
|
|
10
|
+
|
|
11
|
+
SERVICE = APP_NAME
|
|
12
|
+
DESCRIPTION_STR = "Centralized build tools for the standard Maxson architecture."
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
def build_debian_package(app_name: str, version: str, arch: str) -> Path:
|
|
7
|
+
"""Assembles /opt, DEBIAN/control, launcher scripts, and executes dpkg-deb."""
|
|
8
|
+
pkg_dir = Path("deb/pkg")
|
|
9
|
+
pkg_dir.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
|
|
11
|
+
# Render control file
|
|
12
|
+
control_in = Path("deb/control.in").read_text()
|
|
13
|
+
control_out = control_in.replace("__VERSION__", version).replace("__ARCH__", arch)
|
|
14
|
+
(pkg_dir / "DEBIAN").mkdir(exist_ok=True)
|
|
15
|
+
(pkg_dir / "DEBIAN/control").write_text(control_out)
|
|
16
|
+
|
|
17
|
+
# Run dpkg-deb
|
|
18
|
+
deb_filename = f"{app_name}_{version}_{arch}.deb"
|
|
19
|
+
output_path = Path(f"deb/{deb_filename}")
|
|
20
|
+
subprocess.run(["fakeroot", "dpkg-deb", "--build", str(pkg_dir), str(output_path)], check=True)
|
|
21
|
+
return output_path
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# maxson_build_utils/src/maxson_build_utils/helpers.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def form_dynamic_name(src_folder_name: str, version: str, mode: Any = None) -> str:
|
|
9
|
+
"""Forms a dynamic output artifact base name."""
|
|
10
|
+
if mode is None:
|
|
11
|
+
return f"{src_folder_name}-{version}"
|
|
12
|
+
|
|
13
|
+
mode_str = mode.value if hasattr(mode, "value") else str(mode)
|
|
14
|
+
return f"{src_folder_name}-{version}-{mode_str}"
|
|
15
|
+
|
|
16
|
+
class PyinsMode(str, Enum):
|
|
17
|
+
ONEDIR = "onedir"
|
|
18
|
+
ONEFILE = "onefile"
|
|
19
|
+
|
|
20
|
+
class IconFileType(str, Enum):
|
|
21
|
+
PNG = "png"
|
|
22
|
+
ICO = "ico"
|
|
23
|
+
SVG = "svg"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# maxson_build_utils/build_utils.py
|
|
3
|
+
|
|
4
|
+
"""Build utilities for packaging PyInstaller outputs across target platforms."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from enum import Enum
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
import pyhabitat
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PyinsMode(str, Enum):
|
|
23
|
+
ONEDIR = "onedir"
|
|
24
|
+
ONEFILE = "onefile"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IconFileType(str, Enum):
|
|
28
|
+
PNG = "png"
|
|
29
|
+
ICO = "ico"
|
|
30
|
+
SVG = "svg"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_icon_filetype(icon_src: Path) -> IconFileType | None:
|
|
34
|
+
"""Resolves and validates the extension of a given icon path."""
|
|
35
|
+
suffix = icon_src.suffix.lower().removeprefix(".")
|
|
36
|
+
try:
|
|
37
|
+
return IconFileType(suffix)
|
|
38
|
+
except ValueError:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_linux_appimage(
|
|
43
|
+
app_dir_path: Path,
|
|
44
|
+
dynamic_exe_name: str,
|
|
45
|
+
app_name_pretty: str,
|
|
46
|
+
icon_src: Path,
|
|
47
|
+
) -> Path:
|
|
48
|
+
"""Packages a PyInstaller ONEDIR bundle into a standalone Linux AppImage."""
|
|
49
|
+
logger.info("Executing build_linux_appimage()")
|
|
50
|
+
logger.info("Source AppDir components from: %s", app_dir_path)
|
|
51
|
+
|
|
52
|
+
if shutil.which("appimagetool") is None:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"appimagetool is not installed. Please download it or install via your package manager."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
upload_dir = Path("dist/upload")
|
|
58
|
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
appimage_output_path = upload_dir / f"{dynamic_exe_name}-x86_64.AppImage"
|
|
60
|
+
|
|
61
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
62
|
+
tmp_dir = Path(tmp)
|
|
63
|
+
staged_appdir = tmp_dir / f"{dynamic_exe_name}.AppDir"
|
|
64
|
+
staged_appdir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
# 1. Populate the usr/bin directory with the PyInstaller bundle
|
|
67
|
+
usr_bin = staged_appdir / "usr" / "bin"
|
|
68
|
+
usr_bin.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
shutil.copytree(app_dir_path, usr_bin / dynamic_exe_name)
|
|
70
|
+
|
|
71
|
+
# 2. Create the AppRun entrypoint script
|
|
72
|
+
apprun_path = staged_appdir / "AppRun"
|
|
73
|
+
apprun_content = f"""#!/bin/sh
|
|
74
|
+
HERE="$(dirname "$(readlink -f "${{0}}")")"
|
|
75
|
+
EXEC="${{HERE}}/usr/bin/{dynamic_exe_name}/{dynamic_exe_name}"
|
|
76
|
+
exec "${{EXEC}}" "$@"
|
|
77
|
+
"""
|
|
78
|
+
apprun_path.write_text(apprun_content, encoding="utf-8")
|
|
79
|
+
apprun_path.chmod(0o755)
|
|
80
|
+
|
|
81
|
+
# 3. Create the .desktop file
|
|
82
|
+
desktop_path = staged_appdir / f"{dynamic_exe_name}.desktop"
|
|
83
|
+
desktop_content = f"""[Desktop Entry]
|
|
84
|
+
Type=Application
|
|
85
|
+
Name={app_name_pretty}
|
|
86
|
+
Exec=AppRun
|
|
87
|
+
Icon={dynamic_exe_name}
|
|
88
|
+
Categories=Utility;
|
|
89
|
+
Terminal=true
|
|
90
|
+
"""
|
|
91
|
+
desktop_path.write_text(desktop_content, encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
# 4. Copy and stage the icon asset
|
|
94
|
+
icon_filetype = resolve_icon_filetype(icon_src)
|
|
95
|
+
if icon_filetype is None:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Unsupported icon type: '{icon_src.suffix}'. "
|
|
98
|
+
f"Supported types: {[x.value for x in IconFileType]}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
icon_dst = staged_appdir / f"{dynamic_exe_name}.{icon_filetype.value}"
|
|
102
|
+
|
|
103
|
+
logger.info("Staging Linux AppImage icon: %s -> %s", icon_src.name, icon_dst.name)
|
|
104
|
+
if icon_src.exists():
|
|
105
|
+
shutil.copy2(icon_src, icon_dst)
|
|
106
|
+
else:
|
|
107
|
+
raise FileNotFoundError(
|
|
108
|
+
f"Critical asset missing! Could not locate icon at: {icon_src.resolve()}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# 5. Run appimagetool
|
|
112
|
+
logger.info("Compiling AppImage to %s...", appimage_output_path)
|
|
113
|
+
subprocess.run(
|
|
114
|
+
[
|
|
115
|
+
"appimagetool",
|
|
116
|
+
str(staged_appdir.resolve()),
|
|
117
|
+
str(appimage_output_path.resolve()),
|
|
118
|
+
],
|
|
119
|
+
check=True,
|
|
120
|
+
env=os.environ.copy(),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return appimage_output_path
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def post_process_linux_build(
|
|
127
|
+
app_path: Path,
|
|
128
|
+
dynamic_exe_name: str,
|
|
129
|
+
app_name_pretty: str,
|
|
130
|
+
icon_src: Path,
|
|
131
|
+
mode: PyinsMode,
|
|
132
|
+
) -> Path | None:
|
|
133
|
+
"""Handles downstream staging tasks on Linux platforms (skips Termux)."""
|
|
134
|
+
is_linux = getattr(pyhabitat, "on_linux", lambda: sys.platform.startswith("linux"))()
|
|
135
|
+
is_termux = getattr(pyhabitat, "on_termux", lambda: False)()
|
|
136
|
+
|
|
137
|
+
if is_linux and not is_termux and mode == PyinsMode.ONEDIR:
|
|
138
|
+
bundle_dir = app_path.parent
|
|
139
|
+
return build_linux_appimage(
|
|
140
|
+
app_dir_path=bundle_dir,
|
|
141
|
+
dynamic_exe_name=dynamic_exe_name,
|
|
142
|
+
app_name_pretty=app_name_pretty,
|
|
143
|
+
icon_src=icon_src,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return None
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# src/maxson_build_utils/macos_dmg.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import pyhabitat
|
|
11
|
+
|
|
12
|
+
from maxson_build_utils.build_utils import PyinsMode
|
|
13
|
+
|
|
14
|
+
STANDARD_MACOS_APP_DIST_DIR = Path("dist")
|
|
15
|
+
DIST_DIR_ONEDIR = Path("dist") / PyinsMode.ONEDIR.value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def purge_raw_unix_structure_from_macos_build(dynamic_exe_name: str, mode: PyinsMode) -> None:
|
|
19
|
+
"""Removes duplicate CLI directories created by PyInstaller next to .app bundles."""
|
|
20
|
+
if pyhabitat.on_macos() and mode == PyinsMode.ONEDIR:
|
|
21
|
+
duplicate_cli_dir = Path("dist") / dynamic_exe_name
|
|
22
|
+
if duplicate_cli_dir.exists() and duplicate_cli_dir.is_dir():
|
|
23
|
+
print(f"Cleaning up duplicate raw Unix folder: {duplicate_cli_dir.resolve()}")
|
|
24
|
+
shutil.rmtree(duplicate_cli_dir)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def move_macos_app(macos_app_filename: str, app_path: Path) -> Path:
|
|
28
|
+
"""Relocates standard PyInstaller .app bundle into dist/onedir/."""
|
|
29
|
+
src = STANDARD_MACOS_APP_DIST_DIR / macos_app_filename
|
|
30
|
+
dst = DIST_DIR_ONEDIR / macos_app_filename
|
|
31
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
if src.exists():
|
|
34
|
+
if dst.exists():
|
|
35
|
+
shutil.rmtree(dst)
|
|
36
|
+
shutil.move(str(src), str(dst))
|
|
37
|
+
return dst
|
|
38
|
+
return app_path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_macos_dmg(
|
|
42
|
+
app: Path,
|
|
43
|
+
app_pretty_name: str,
|
|
44
|
+
version: str,
|
|
45
|
+
output_dir: Path = Path("dist/upload"),
|
|
46
|
+
) -> Path:
|
|
47
|
+
"""Packages a macOS .app bundle into a .dmg using create-dmg."""
|
|
48
|
+
print("build_macos_dmg()")
|
|
49
|
+
print(f"{app=}")
|
|
50
|
+
|
|
51
|
+
if app.suffix != ".app":
|
|
52
|
+
raise ValueError(f"Expected a .app bundle, got {app}")
|
|
53
|
+
|
|
54
|
+
if shutil.which("create-dmg") is None:
|
|
55
|
+
raise RuntimeError("create-dmg is not installed. Install with: brew install create-dmg")
|
|
56
|
+
|
|
57
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
dmg_path = output_dir / f"{app.stem}.dmg"
|
|
59
|
+
|
|
60
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
61
|
+
tmp_path = Path(tmp)
|
|
62
|
+
staged = tmp_path / app.name
|
|
63
|
+
shutil.copytree(app, staged)
|
|
64
|
+
|
|
65
|
+
cmd = [
|
|
66
|
+
"create-dmg",
|
|
67
|
+
"--volname",
|
|
68
|
+
f"{app_pretty_name} {version}",
|
|
69
|
+
"--window-size",
|
|
70
|
+
"500",
|
|
71
|
+
"300",
|
|
72
|
+
"--icon-size",
|
|
73
|
+
"100",
|
|
74
|
+
"--icon",
|
|
75
|
+
staged.name,
|
|
76
|
+
"150",
|
|
77
|
+
"180",
|
|
78
|
+
"--app-drop-link",
|
|
79
|
+
"450",
|
|
80
|
+
"180",
|
|
81
|
+
str(dmg_path.resolve()),
|
|
82
|
+
str(tmp_path),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
print(f"Executing create-dmg command: {' '.join(cmd)}")
|
|
86
|
+
subprocess.run(cmd, check=True)
|
|
87
|
+
|
|
88
|
+
return dmg_path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def post_process_macos_build(
|
|
92
|
+
app_filename: str,
|
|
93
|
+
app_path: Path,
|
|
94
|
+
mode: PyinsMode,
|
|
95
|
+
app_pretty_name: str,
|
|
96
|
+
version: str,
|
|
97
|
+
) -> tuple[Path, Path | None]:
|
|
98
|
+
"""Coordinates post-build cleanup, app relocation, and DMG generation for macOS."""
|
|
99
|
+
if pyhabitat.on_macos() and mode == PyinsMode.ONEDIR:
|
|
100
|
+
app_path = move_macos_app(app_filename, app_path)
|
|
101
|
+
dmg_path = build_macos_dmg(
|
|
102
|
+
app=app_path,
|
|
103
|
+
app_pretty_name=app_pretty_name,
|
|
104
|
+
version=version,
|
|
105
|
+
)
|
|
106
|
+
return app_path, dmg_path
|
|
107
|
+
return app_path, None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# src/maxson_build_utils/vendor.py
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
def vendor_wheels(dist_dir: Path = Path("dist"), vendor_dir: Path = Path("vendor-wheels")) -> None:
|
|
6
|
+
"""Builds project wheel and downloads all runtime dependencies offline for Flatpak."""
|
|
7
|
+
vendor_dir.mkdir(parents=True, exist_ok=True)
|
|
8
|
+
|
|
9
|
+
# 1. Build the project wheel first
|
|
10
|
+
subprocess.run(["uv", "build", "--wheel", "--out-dir", str(dist_dir)], check=True)
|
|
11
|
+
|
|
12
|
+
# 2. Export ONLY third-party runtime dependencies (exclude dev deps and the local project itself)
|
|
13
|
+
req_file = Path("requirements.txt")
|
|
14
|
+
subprocess.run([
|
|
15
|
+
"uv", "export",
|
|
16
|
+
"--format", "requirements-txt",
|
|
17
|
+
"--no-editable",
|
|
18
|
+
"--no-dev",
|
|
19
|
+
"--no-emit-project",
|
|
20
|
+
"-o", str(req_file)
|
|
21
|
+
], check=True)
|
|
22
|
+
|
|
23
|
+
# 3. Download third-party runtime wheels
|
|
24
|
+
subprocess.run([
|
|
25
|
+
"uv", "run", "pip", "download",
|
|
26
|
+
"-r", str(req_file),
|
|
27
|
+
"-d", str(vendor_dir)
|
|
28
|
+
], check=True)
|
|
29
|
+
|
|
30
|
+
# 4. Stage the built primary wheel into vendor-wheels without checking PyPI dependencies
|
|
31
|
+
wheels = list(dist_dir.glob("*.whl"))
|
|
32
|
+
if not wheels:
|
|
33
|
+
raise FileNotFoundError(f"No built wheels found in {dist_dir}")
|
|
34
|
+
|
|
35
|
+
subprocess.run([
|
|
36
|
+
"uv", "run", "pip", "download",
|
|
37
|
+
str(wheels[0]),
|
|
38
|
+
"--no-deps",
|
|
39
|
+
"-d", str(vendor_dir)
|
|
40
|
+
], check=True)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maxson-build-utils
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Common tools for deploying packages to EXE, MSIX, DMG, DEB, PYZ, flatpak, and more. Templated approach to mkdocs.
|
|
5
|
+
Author-email: George Clayton Bennett <george.bennett@memphistn.gov>
|
|
6
|
+
Maintainer-email: George Clayton Bennett <george.bennett@memphistn.gov>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/city-of-memphis-wastewater/maxson-build-utils
|
|
9
|
+
Project-URL: Repository, https://github.com/city-of-memphis-wastewater/maxson-build-utils
|
|
10
|
+
Project-URL: Issues, https://github.com/city-of-memphis-wastewater/maxson-build-utils/issues
|
|
11
|
+
Project-URL: Changelog, https://raw.githubusercontent.com/city-of-memphis-wastewater/maxson-build-utils/main/docs/CHANGELOG.md
|
|
12
|
+
Keywords: build-tools,build
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Intended Audience :: Developers
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Classifier: Development Status :: 3 - Alpha
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: pyhabitat>=1.3.9
|
|
27
|
+
Requires-Dist: typer>=0.27.0
|
|
28
|
+
Requires-Dist: typer-helptree>=0.2.12
|
|
29
|
+
|
|
30
|
+
# maxson-build-utils
|
|
31
|
+
|
|
32
|
+
Centralized org tooling for PyInstaller, DMG, AppImage, DEB, PYZ, etc.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/maxson_build_utils/VERSION
|
|
4
|
+
src/maxson_build_utils/__init__.py
|
|
5
|
+
src/maxson_build_utils/__main__.py
|
|
6
|
+
src/maxson_build_utils/_version.py
|
|
7
|
+
src/maxson_build_utils/build_executable.py
|
|
8
|
+
src/maxson_build_utils/build_pyz.py
|
|
9
|
+
src/maxson_build_utils/build_utils.py
|
|
10
|
+
src/maxson_build_utils/cli.py
|
|
11
|
+
src/maxson_build_utils/context.py
|
|
12
|
+
src/maxson_build_utils/deb.py
|
|
13
|
+
src/maxson_build_utils/helpers.py
|
|
14
|
+
src/maxson_build_utils/linux_app_image.py
|
|
15
|
+
src/maxson_build_utils/macos_dmg.py
|
|
16
|
+
src/maxson_build_utils/vendor.py
|
|
17
|
+
src/maxson_build_utils.egg-info/PKG-INFO
|
|
18
|
+
src/maxson_build_utils.egg-info/SOURCES.txt
|
|
19
|
+
src/maxson_build_utils.egg-info/dependency_links.txt
|
|
20
|
+
src/maxson_build_utils.egg-info/entry_points.txt
|
|
21
|
+
src/maxson_build_utils.egg-info/requires.txt
|
|
22
|
+
src/maxson_build_utils.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
maxson_build_utils
|