pyappdist 0.1.0__py3-none-any.whl

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.
pyappdist/__init__.py ADDED
File without changes
pyappdist/_hostexec.py ADDED
@@ -0,0 +1,30 @@
1
+ """Execution helpers that absorb host/target differences.
2
+
3
+ When handling a Windows target from WSL (a Linux host), Windows tools (uv.exe)
4
+ must be invoked and paths converted to Windows form (wslpath -w).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from .targets import Target
14
+
15
+
16
+ def is_cross_windows(target: Target) -> bool:
17
+ """Handling a Windows target from a Linux host (requires the .exe bridge)."""
18
+ return target.os == "windows" and sys.platform != "win32"
19
+
20
+
21
+ def target_path(target: Target, path: Path | str) -> str:
22
+ """Path string to pass to a target tool."""
23
+ p = Path(path)
24
+ if is_cross_windows(target):
25
+ out = subprocess.run(
26
+ ["wslpath", "-w", str(p)], capture_output=True, text=True,
27
+ errors="replace", check=True,
28
+ )
29
+ return out.stdout.strip()
30
+ return str(p)
pyappdist/cli.py ADDED
@@ -0,0 +1,199 @@
1
+ """pyappdist CLI.
2
+
3
+ Subcommands:
4
+ build-wheels app + dependency wheels into appdist/wheelhouse
5
+ fetch-runtime python-build-standalone runtime into appdist/runtime
6
+ build-image runtime + install + compileall + launcher + portable zip into appdist/image
7
+ build-launchers build launcher.exe inside the image (Windows, MSVC)
8
+ gen-wix scan the image and generate WiX XML (.wxs)
9
+ build run wheels->runtime->image->launcher->wix->MSI in one go
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import dataclasses
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from . import image as image_mod
20
+ from .config import ensure_upgrade_code, load_config
21
+ from .context import BuildContext
22
+ from .errors import BuildError, PyappdistError
23
+ from .launcher import build_launchers
24
+ from .runtime import fetch_runtime
25
+ from .sign import sign_artifact
26
+ from .wheels import build_wheelhouse
27
+ from .wix import build_msi, generate_wxs, scan_image
28
+
29
+
30
+ def _build_context(args: argparse.Namespace) -> BuildContext:
31
+ project_dir = Path(args.project).resolve()
32
+ config = load_config(project_dir, target_override=args.target)
33
+ out_dir = Path(args.out_dir).resolve() if args.out_dir else project_dir / "appdist"
34
+ return BuildContext(config=config, out_dir=out_dir)
35
+
36
+
37
+ def _do_fetch_runtime(ctx: BuildContext, args: argparse.Namespace):
38
+ return fetch_runtime(
39
+ ctx.config.target,
40
+ ctx.config.python,
41
+ ctx.runtime_dir,
42
+ runtime_source=Path(args.runtime_source) if args.runtime_source else None,
43
+ runtime_release=args.runtime_release,
44
+ )
45
+
46
+
47
+ def cmd_build_wheels(args: argparse.Namespace) -> int:
48
+ ctx = _build_context(args)
49
+ # Dependencies are resolved with the target runtime's python, so prepare the runtime first.
50
+ info = _do_fetch_runtime(ctx, args)
51
+ wheels = build_wheelhouse(ctx.config, info, ctx.wheelhouse)
52
+ print(f"OK: {len(wheels)} wheel -> {ctx.wheelhouse}")
53
+ return 0
54
+
55
+
56
+ def cmd_fetch_runtime(args: argparse.Namespace) -> int:
57
+ ctx = _build_context(args)
58
+ info = _do_fetch_runtime(ctx, args)
59
+ print(f"OK: runtime {info.version} -> {ctx.runtime_dir}")
60
+ return 0
61
+
62
+
63
+ def cmd_build_image(args: argparse.Namespace) -> int:
64
+ ctx = _build_context(args)
65
+ info = _do_fetch_runtime(ctx, args)
66
+ build_wheelhouse(ctx.config, info, ctx.wheelhouse)
67
+ layout = image_mod.build_image(ctx, info, compile_pyc=not args.no_compile)
68
+ exes = build_launchers(ctx.config, layout, ctx.out_dir / "_launcher_build")
69
+ # The zip includes launcher.exe, so do it after the launcher build.
70
+ if not args.no_zip:
71
+ image_mod.make_portable_zip(ctx)
72
+ print(f"OK: image -> {layout.image_dir} ({len(exes)} launcher)")
73
+ return 0
74
+
75
+
76
+ def cmd_build_launchers(args: argparse.Namespace) -> int:
77
+ ctx = _build_context(args)
78
+ if not ctx.image_dir.is_dir():
79
+ raise BuildError(
80
+ f"image is missing: {ctx.image_dir} (run build-image first)"
81
+ )
82
+ layout = image_mod.ImageLayout(
83
+ image_dir=ctx.image_dir,
84
+ target=ctx.config.target,
85
+ minor=ctx.config.python_minor,
86
+ )
87
+ exes = build_launchers(ctx.config, layout, ctx.out_dir / "_launcher_build")
88
+ print(f"OK: {len(exes)} launcher -> {layout.image_dir}")
89
+ return 0
90
+
91
+
92
+ def _write_wxs(ctx: BuildContext) -> Path:
93
+ # Ensure a stable upgrade_code exists (generated and persisted to pyproject.toml if unset).
94
+ upgrade_code = ensure_upgrade_code(ctx.config.project_dir)
95
+ config = ctx.config
96
+ if config.wix.upgrade_code != upgrade_code:
97
+ config = dataclasses.replace(
98
+ config, wix=dataclasses.replace(config.wix, upgrade_code=upgrade_code)
99
+ )
100
+ tree = scan_image(ctx.image_dir)
101
+ xml = generate_wxs(config, tree)
102
+ wxs = ctx.out_dir / f"{ctx.config.dist_name}.wxs"
103
+ wxs.write_text(xml, encoding="utf-8")
104
+ return wxs
105
+
106
+
107
+ def cmd_gen_wix(args: argparse.Namespace) -> int:
108
+ ctx = _build_context(args)
109
+ if not ctx.image_dir.is_dir():
110
+ raise BuildError(f"image is missing: {ctx.image_dir} (run build-image first)")
111
+ wxs = _write_wxs(ctx)
112
+ print(f"OK: wxs -> {wxs}")
113
+ return 0
114
+
115
+
116
+ def cmd_build(args: argparse.Namespace) -> int:
117
+ """Run wheelhouse -> runtime -> image -> launcher -> wix -> MSI in one go (Phase 5)."""
118
+ ctx = _build_context(args)
119
+ info = _do_fetch_runtime(ctx, args)
120
+ build_wheelhouse(ctx.config, info, ctx.wheelhouse)
121
+ layout = image_mod.build_image(ctx, info, compile_pyc=not args.no_compile)
122
+ exes = build_launchers(ctx.config, layout, ctx.out_dir / "_launcher_build")
123
+ for exe in exes:
124
+ sign_artifact(exe)
125
+ if not args.no_zip:
126
+ image_mod.make_portable_zip(ctx)
127
+
128
+ wxs = _write_wxs(ctx)
129
+ msi_name = f"{ctx.config.dist_name}-{ctx.config.version}.msi"
130
+ msi = build_msi(ctx.config, ctx.image_dir, wxs, ctx.dist_dir / msi_name)
131
+ if msi is not None:
132
+ sign_artifact(msi)
133
+ print(f"OK: msi -> {msi} ({len(exes)} launcher)")
134
+ else:
135
+ print(f"OK: image -> {layout.image_dir} (msi skipped on non-Windows)")
136
+ return 0
137
+
138
+
139
+ def _add_common(p: argparse.ArgumentParser) -> None:
140
+ p.add_argument("project", nargs="?", default=".", help="the app's project directory")
141
+ p.add_argument("--target", help="distribution target (e.g. windows-x86_64 / linux-x86_64)")
142
+ p.add_argument("--out-dir", help="output directory (default: <project>/appdist)")
143
+
144
+
145
+ def _add_runtime_opts(p: argparse.ArgumentParser) -> None:
146
+ p.add_argument("--runtime-release", help="pin the python-build-standalone release tag")
147
+ p.add_argument("--runtime-source", help="local runtime tar.gz (offline)")
148
+
149
+
150
+ def build_parser() -> argparse.ArgumentParser:
151
+ parser = argparse.ArgumentParser(prog="pyappdist", description="Create a Windows distribution of a Python app")
152
+ sub = parser.add_subparsers(dest="command", required=True)
153
+
154
+ p = sub.add_parser("build-wheels", help="wheels into appdist/wheelhouse")
155
+ _add_common(p)
156
+ _add_runtime_opts(p)
157
+ p.set_defaults(func=cmd_build_wheels)
158
+
159
+ p = sub.add_parser("fetch-runtime", help="runtime into appdist/runtime")
160
+ _add_common(p)
161
+ _add_runtime_opts(p)
162
+ p.set_defaults(func=cmd_fetch_runtime)
163
+
164
+ p = sub.add_parser("build-image", help="image into appdist/image")
165
+ _add_common(p)
166
+ _add_runtime_opts(p)
167
+ p.add_argument("--no-compile", action="store_true", help="skip compileall")
168
+ p.add_argument("--no-zip", action="store_true", help="do not create a portable zip")
169
+ p.set_defaults(func=cmd_build_image)
170
+
171
+ p = sub.add_parser("build-launchers", help="build launcher.exe into the image (Windows)")
172
+ _add_common(p)
173
+ p.set_defaults(func=cmd_build_launchers)
174
+
175
+ p = sub.add_parser("gen-wix", help="generate WiX XML (.wxs) from the image")
176
+ _add_common(p)
177
+ p.set_defaults(func=cmd_gen_wix)
178
+
179
+ p = sub.add_parser("build", help="run wheels->runtime->image->launcher->wix->MSI in one go")
180
+ _add_common(p)
181
+ _add_runtime_opts(p)
182
+ p.add_argument("--no-compile", action="store_true")
183
+ p.add_argument("--no-zip", action="store_true")
184
+ p.set_defaults(func=cmd_build)
185
+
186
+ return parser
187
+
188
+
189
+ def main(argv: list[str] | None = None) -> int:
190
+ args = build_parser().parse_args(argv)
191
+ try:
192
+ return args.func(args)
193
+ except PyappdistError as e:
194
+ print(f"error: {e}", file=sys.stderr)
195
+ return 1
196
+
197
+
198
+ if __name__ == "__main__":
199
+ raise SystemExit(main())
pyappdist/config.py ADDED
@@ -0,0 +1,176 @@
1
+ """Loading and validation of ``[tool.pyappdist]``.
2
+
3
+ Treats pyproject.toml as the single source of truth and normalizes it into a
4
+ typed dataclass.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import tomllib
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from .errors import ConfigError
15
+ from .targets import Target, get_target
16
+
17
+ _PYTHON_RE = re.compile(r"^\d+\.\d+(\.\d+)?$")
18
+
19
+ _MANAGERS = ("uv", "poetry", "pipenv", "pdm", "requirements.txt")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class LauncherConfig:
24
+ name: str # output exe name (without extension)
25
+ entry: str # "module:callable"
26
+ gui: bool = False
27
+ icon: str | None = None
28
+ args: str = "" # fixed arguments (single string)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class WixConfig:
33
+ manufacturer: str | None = None
34
+ upgrade_code: str | None = None
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Config:
39
+ project_dir: Path
40
+ name: str # display name
41
+ dist_name: str # distribution package name ([project].name)
42
+ version: str
43
+ python: str # "X.Y" or "X.Y.Z"
44
+ target: Target
45
+ identifier: str | None
46
+ launchers: tuple[LauncherConfig, ...]
47
+ wix: WixConfig
48
+ manager: str | None # manager used for dependency resolution (uv/poetry/pipenv/pdm/requirements.txt). None=auto-detect
49
+
50
+ @property
51
+ def python_minor(self) -> str:
52
+ parts = self.python.split(".")
53
+ return f"{parts[0]}.{parts[1]}"
54
+
55
+
56
+ def load_config(project_dir: Path, *, target_override: str | None = None) -> Config:
57
+ project_dir = Path(project_dir).resolve()
58
+ pyproject = project_dir / "pyproject.toml"
59
+ if not pyproject.is_file():
60
+ raise ConfigError(f"pyproject.toml not found: {pyproject}")
61
+
62
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
63
+ project = data.get("project", {})
64
+ tool = data.get("tool", {}).get("pyappdist")
65
+ if tool is None:
66
+ raise ConfigError(f"[tool.pyappdist] is missing: {pyproject}")
67
+
68
+ dist_name = project.get("name")
69
+ if not dist_name:
70
+ raise ConfigError("[project].name is required")
71
+ name = tool.get("name") or dist_name
72
+
73
+ version = tool.get("version") or project.get("version") or "0.0.0"
74
+
75
+ python = tool.get("python")
76
+ if not python:
77
+ raise ConfigError("[tool.pyappdist].python is required (e.g. \"3.12\")")
78
+ if not _PYTHON_RE.match(str(python)):
79
+ raise ConfigError(f"python must be in X.Y or X.Y.Z format: {python!r}")
80
+
81
+ target_name = target_override or tool.get("target") or "windows-x86_64"
82
+ target = get_target(target_name)
83
+
84
+ launchers = _parse_launchers(tool.get("launchers"))
85
+ wix = _parse_wix(tool.get("wix"))
86
+
87
+ manager = tool.get("manager")
88
+ if manager is not None and manager not in _MANAGERS:
89
+ raise ConfigError(
90
+ f"[tool.pyappdist].manager must be one of {_MANAGERS}: {manager!r}"
91
+ )
92
+
93
+ return Config(
94
+ project_dir=project_dir,
95
+ name=str(name),
96
+ dist_name=str(dist_name),
97
+ version=str(version),
98
+ python=str(python),
99
+ target=target,
100
+ identifier=tool.get("identifier"),
101
+ launchers=launchers,
102
+ wix=wix,
103
+ manager=manager,
104
+ )
105
+
106
+
107
+ def _parse_launchers(raw: object) -> tuple[LauncherConfig, ...]:
108
+ if raw is None:
109
+ return ()
110
+ if not isinstance(raw, list):
111
+ raise ConfigError("[tool.pyappdist].launchers must be an array")
112
+ out: list[LauncherConfig] = []
113
+ for i, item in enumerate(raw):
114
+ if not isinstance(item, dict):
115
+ raise ConfigError(f"launchers[{i}] must be a table")
116
+ name = item.get("name")
117
+ entry = item.get("entry")
118
+ if not name:
119
+ raise ConfigError(f"launchers[{i}].name is required")
120
+ if not entry or ":" not in str(entry):
121
+ raise ConfigError(
122
+ f"launchers[{i}].entry must be in \"module:callable\" format: {entry!r}"
123
+ )
124
+ out.append(
125
+ LauncherConfig(
126
+ name=str(name),
127
+ entry=str(entry),
128
+ gui=bool(item.get("gui", False)),
129
+ icon=item.get("icon"),
130
+ args=str(item.get("args", "")),
131
+ )
132
+ )
133
+ return tuple(out)
134
+
135
+
136
+ def ensure_upgrade_code(project_dir: Path, *, log=print) -> str:
137
+ """Return the WiX upgrade_code, generating and persisting one if unset.
138
+
139
+ The upgrade code identifies the product across versions for MSI MajorUpgrade,
140
+ so it must stay stable across builds. When it is missing from pyproject.toml we
141
+ generate a UUID and write it back into [tool.pyappdist.wix], editing with
142
+ tomlkit so existing formatting and comments are preserved.
143
+ """
144
+ import uuid
145
+
146
+ import tomlkit
147
+
148
+ from .wix.guid import is_guid
149
+
150
+ pyproject = Path(project_dir).resolve() / "pyproject.toml"
151
+ doc = tomlkit.parse(pyproject.read_text(encoding="utf-8"))
152
+
153
+ wix = doc.get("tool", {}).get("pyappdist", {}).get("wix")
154
+ existing = wix.get("upgrade_code") if wix is not None else None
155
+ if existing and is_guid(str(existing)):
156
+ return str(existing)
157
+
158
+ code = str(uuid.uuid4()).upper()
159
+ tool = doc.setdefault("tool", tomlkit.table())
160
+ pyappdist = tool.setdefault("pyappdist", tomlkit.table())
161
+ wix_tbl = pyappdist.setdefault("wix", tomlkit.table())
162
+ wix_tbl["upgrade_code"] = code
163
+ pyproject.write_text(tomlkit.dumps(doc), encoding="utf-8")
164
+ log(f"wix: generated upgrade_code {code} -> {pyproject}")
165
+ return code
166
+
167
+
168
+ def _parse_wix(raw: object) -> WixConfig:
169
+ if raw is None:
170
+ return WixConfig()
171
+ if not isinstance(raw, dict):
172
+ raise ConfigError("[tool.pyappdist.wix] must be a table")
173
+ return WixConfig(
174
+ manufacturer=raw.get("manufacturer"),
175
+ upgrade_code=raw.get("upgrade_code"),
176
+ )
pyappdist/context.py ADDED
@@ -0,0 +1,30 @@
1
+ """Container for paths and settings shared across the whole build."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .config import Config
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class BuildContext:
13
+ config: Config
14
+ out_dir: Path # appdist/
15
+
16
+ @property
17
+ def wheelhouse(self) -> Path:
18
+ return self.out_dir / "wheelhouse"
19
+
20
+ @property
21
+ def runtime_dir(self) -> Path:
22
+ return self.out_dir / "runtime"
23
+
24
+ @property
25
+ def image_dir(self) -> Path:
26
+ return self.out_dir / "image"
27
+
28
+ @property
29
+ def dist_dir(self) -> Path:
30
+ return self.out_dir / "dist"
pyappdist/deps.py ADDED
@@ -0,0 +1,104 @@
1
+ """Determining the dependency list (package manager lockfile -> requirements.txt).
2
+
3
+ Dependencies are pinned **based on the project's lockfile**. Using the manager
4
+ the developer uses (uv / poetry / pipenv / PDM), ``requirements.txt`` is exported
5
+ from the lock, and later ``pip wheel -r requirements.txt`` is run with the target
6
+ runtime's python. The export emits production dependencies only (dev excluded),
7
+ with cross markers and hashes.
8
+
9
+ Resolution:
10
+ * An explicit ``[tool.pyappdist].manager`` setting takes top priority (if
11
+ ``requirements.txt`` is specified, the requirements.txt directly under the
12
+ project is used as-is).
13
+ * If unset, lockfiles are searched in the order uv.lock -> poetry.lock ->
14
+ Pipfile.lock -> pdm.lock, and the first tool found is used.
15
+ * If undeterminable, a warning is emitted and it operates in ``requirements.txt``
16
+ mode (``BuildError`` if absent).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ from .config import Config
25
+ from .errors import BuildError
26
+
27
+ # tool name -> lockfile name (auto-detect search order).
28
+ _LOCKFILES: tuple[tuple[str, str], ...] = (
29
+ ("uv", "uv.lock"),
30
+ ("poetry", "poetry.lock"),
31
+ ("pipenv", "Pipfile.lock"),
32
+ ("pdm", "pdm.lock"),
33
+ )
34
+
35
+ # tool name -> export command (run with cwd=project_dir, stdout becomes requirements.txt).
36
+ # All emit production dependencies only (dev excluded), with hashes.
37
+ _EXPORT_CMDS: dict[str, list[str]] = {
38
+ "uv": ["uv", "export", "--frozen", "--no-dev", "--no-emit-project", "--format", "requirements-txt"],
39
+ "poetry": ["poetry", "export", "-f", "requirements.txt", "--without", "dev"],
40
+ "pipenv": ["pipenv", "requirements", "--hash"],
41
+ "pdm": ["pdm", "export", "-f", "requirements", "--prod"],
42
+ }
43
+
44
+
45
+ def _auto_detect(project_dir: Path) -> str | None:
46
+ """Detect the manager from the presence of a lockfile (None if absent)."""
47
+ for manager, lockfile in _LOCKFILES:
48
+ if (project_dir / lockfile).is_file():
49
+ return manager
50
+ return None
51
+
52
+
53
+ def _warn(log, message: str) -> None:
54
+ log(f"warning: {message}")
55
+
56
+
57
+ def resolve_manager(project_dir: Path, override: str | None, *, log=print) -> str:
58
+ """Determine the manager to use (or "requirements.txt")."""
59
+ if override:
60
+ return override
61
+ detected = _auto_detect(project_dir)
62
+ if detected:
63
+ return detected
64
+ _warn(
65
+ log,
66
+ "no lockfile (uv.lock etc.) and no [tool.pyappdist].manager setting. "
67
+ "Falling back to requirements.txt",
68
+ )
69
+ return "requirements.txt"
70
+
71
+
72
+ def resolve_requirements(config: Config, wheelhouse: Path, *, log=print) -> Path:
73
+ """Prepare the pinned dependency list at ``wheelhouse/requirements.txt`` and return its path."""
74
+ manager = resolve_manager(config.project_dir, config.manager, log=log)
75
+ out = wheelhouse / "requirements.txt"
76
+
77
+ if manager == "requirements.txt":
78
+ src = config.project_dir / "requirements.txt"
79
+ if not src.is_file():
80
+ raise BuildError(
81
+ f"requirements.txt is missing: {src}"
82
+ " (provide a manager lockfile or place a requirements.txt)"
83
+ )
84
+ log(f"deps: using requirements.txt ({src})")
85
+ out.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
86
+ return out
87
+
88
+ cmd = _EXPORT_CMDS[manager]
89
+ log(f"deps: exporting requirements.txt from {manager} lock")
90
+ proc = subprocess.run(
91
+ cmd,
92
+ cwd=str(config.project_dir),
93
+ capture_output=True,
94
+ text=True,
95
+ encoding="utf-8",
96
+ errors="replace",
97
+ )
98
+ if proc.returncode != 0:
99
+ raise BuildError(
100
+ f"requirements.txt export failed ({proc.returncode}): {' '.join(cmd)}\n"
101
+ f"{proc.stderr.strip()}"
102
+ )
103
+ out.write_text(proc.stdout, encoding="utf-8")
104
+ return out
pyappdist/errors.py ADDED
@@ -0,0 +1,15 @@
1
+ """pyappdist exception hierarchy."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class PyappdistError(Exception):
7
+ """Base for all pyappdist exceptions."""
8
+
9
+
10
+ class ConfigError(PyappdistError):
11
+ """A problem in pyproject.toml / [tool.pyappdist]."""
12
+
13
+
14
+ class BuildError(PyappdistError):
15
+ """A failure in a build step (wheel / runtime / image, etc.)."""
@@ -0,0 +1,37 @@
1
+ """Image assembly (Phase 2).
2
+
3
+ build_image: copy runtime -> install -> compileall -> portable zip.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from ..context import BuildContext
11
+ from ..runtime import RuntimeInfo
12
+ from .assemble import assemble_runtime, make_portable_zip
13
+ from .compile import compile_site_packages
14
+ from .install import install_app
15
+ from .layout import ImageLayout
16
+
17
+ __all__ = ["ImageLayout", "build_image", "make_portable_zip"]
18
+
19
+
20
+ def build_image(
21
+ ctx: BuildContext,
22
+ runtime: RuntimeInfo,
23
+ *,
24
+ compile_pyc: bool = True,
25
+ log=print,
26
+ ) -> ImageLayout:
27
+ """Copy the runtime and assemble an image with install + compileall applied.
28
+
29
+ Building launcher.exe and creating the portable zip are the caller's (cli's)
30
+ responsibility. The zip must be created after the launcher build so it
31
+ includes the launcher.
32
+ """
33
+ layout = assemble_runtime(ctx, runtime, log=log)
34
+ install_app(layout, ctx.wheelhouse, log=log)
35
+ if compile_pyc:
36
+ compile_site_packages(layout, log=log)
37
+ return layout
@@ -0,0 +1,30 @@
1
+ """Copy the runtime to assemble an image and generate a portable zip."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from ..context import BuildContext
9
+ from ..runtime import RuntimeInfo
10
+ from .layout import ImageLayout
11
+
12
+
13
+ def assemble_runtime(ctx: BuildContext, runtime: RuntimeInfo, *, log=print) -> ImageLayout:
14
+ """Copy the runtime to image/python and return an ImageLayout."""
15
+ image_dir = ctx.image_dir
16
+ if image_dir.exists():
17
+ shutil.rmtree(image_dir)
18
+ image_dir.mkdir(parents=True)
19
+ python_dir = image_dir / "python"
20
+ log(f"image: copying runtime -> {python_dir}")
21
+ shutil.copytree(runtime.root, python_dir, symlinks=True)
22
+ return ImageLayout(image_dir=image_dir, target=ctx.config.target, minor=runtime.minor)
23
+
24
+
25
+ def make_portable_zip(ctx: BuildContext, *, log=print) -> Path:
26
+ ctx.dist_dir.mkdir(parents=True, exist_ok=True)
27
+ base = ctx.dist_dir / f"{ctx.config.dist_name}-{ctx.config.version}-portable"
28
+ log(f"image: generating portable zip -> {base}.zip")
29
+ archive = shutil.make_archive(str(base), "zip", root_dir=ctx.image_dir)
30
+ return Path(archive)
@@ -0,0 +1,32 @@
1
+ """Generate .pyc files for the image's site-packages at build time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+
7
+ from .._hostexec import target_path
8
+ from ..errors import BuildError
9
+ from .layout import ImageLayout
10
+
11
+
12
+ def compile_site_packages(layout: ImageLayout, *, log=print) -> None:
13
+ """Run compileall with the runtime's python.
14
+
15
+ The target OS's python must be run (for a Linux host -> Windows target,
16
+ python.exe is run via WSL interop and paths are converted to Windows form).
17
+ Raises BuildError in environments where it cannot be run.
18
+ """
19
+ target = layout.target
20
+ log("image: compileall")
21
+ cmd = [
22
+ str(layout.python_exe), "-m", "compileall", "-q",
23
+ target_path(target, layout.site_packages),
24
+ ]
25
+ try:
26
+ proc = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
27
+ except OSError as e:
28
+ raise BuildError(
29
+ f"cannot run compileall (unable to run {target.name}'s python): {e}"
30
+ ) from e
31
+ if proc.returncode != 0:
32
+ raise BuildError(f"compileall failed ({proc.returncode}):\n{proc.stderr.strip()}")