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.
Files changed (24) hide show
  1. maxson_build_utils-0.1.1/PKG-INFO +32 -0
  2. maxson_build_utils-0.1.1/README.md +3 -0
  3. maxson_build_utils-0.1.1/pyproject.toml +77 -0
  4. maxson_build_utils-0.1.1/setup.cfg +4 -0
  5. maxson_build_utils-0.1.1/src/maxson_build_utils/VERSION +2 -0
  6. maxson_build_utils-0.1.1/src/maxson_build_utils/__init__.py +5 -0
  7. maxson_build_utils-0.1.1/src/maxson_build_utils/__main__.py +6 -0
  8. maxson_build_utils-0.1.1/src/maxson_build_utils/_version.py +22 -0
  9. maxson_build_utils-0.1.1/src/maxson_build_utils/build_executable.py +411 -0
  10. maxson_build_utils-0.1.1/src/maxson_build_utils/build_pyz.py +160 -0
  11. maxson_build_utils-0.1.1/src/maxson_build_utils/build_utils.py +25 -0
  12. maxson_build_utils-0.1.1/src/maxson_build_utils/cli.py +29 -0
  13. maxson_build_utils-0.1.1/src/maxson_build_utils/context.py +12 -0
  14. maxson_build_utils-0.1.1/src/maxson_build_utils/deb.py +21 -0
  15. maxson_build_utils-0.1.1/src/maxson_build_utils/helpers.py +23 -0
  16. maxson_build_utils-0.1.1/src/maxson_build_utils/linux_app_image.py +146 -0
  17. maxson_build_utils-0.1.1/src/maxson_build_utils/macos_dmg.py +107 -0
  18. maxson_build_utils-0.1.1/src/maxson_build_utils/vendor.py +40 -0
  19. maxson_build_utils-0.1.1/src/maxson_build_utils.egg-info/PKG-INFO +32 -0
  20. maxson_build_utils-0.1.1/src/maxson_build_utils.egg-info/SOURCES.txt +22 -0
  21. maxson_build_utils-0.1.1/src/maxson_build_utils.egg-info/dependency_links.txt +1 -0
  22. maxson_build_utils-0.1.1/src/maxson_build_utils.egg-info/entry_points.txt +2 -0
  23. maxson_build_utils-0.1.1/src/maxson_build_utils.egg-info/requires.txt +3 -0
  24. 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,3 @@
1
+ # maxson-build-utils
2
+
3
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ # maxson_build_utils/src/maxson_build_utils/__init__.py
2
+ from maxson_build_utils.build_executable import run_build_executable
3
+ from maxson_build_utils.build_pyz import run_build_pyz
4
+
5
+ __all__ = ["run_build_executable", "run_build_pyz"]
@@ -0,0 +1,6 @@
1
+ # src/maxson_build_utils/__main__.py
2
+ from __future__ import annotations
3
+ from maxson_build_utils.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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,2 @@
1
+ [console_scripts]
2
+ maxson-build-utils = maxson_build_utils.__main__:main
@@ -0,0 +1,3 @@
1
+ pyhabitat>=1.3.9
2
+ typer>=0.27.0
3
+ typer-helptree>=0.2.12
@@ -0,0 +1 @@
1
+ maxson_build_utils