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 +0 -0
- pyappdist/_hostexec.py +30 -0
- pyappdist/cli.py +199 -0
- pyappdist/config.py +176 -0
- pyappdist/context.py +30 -0
- pyappdist/deps.py +104 -0
- pyappdist/errors.py +15 -0
- pyappdist/image/__init__.py +37 -0
- pyappdist/image/assemble.py +30 -0
- pyappdist/image/compile.py +32 -0
- pyappdist/image/install.py +37 -0
- pyappdist/image/layout.py +35 -0
- pyappdist/launcher/__init__.py +7 -0
- pyappdist/launcher/build.py +211 -0
- pyappdist/resources/launcher.c +152 -0
- pyappdist/runtime.py +259 -0
- pyappdist/sign.py +42 -0
- pyappdist/targets.py +46 -0
- pyappdist/wheels.py +92 -0
- pyappdist/wix/__init__.py +15 -0
- pyappdist/wix/build.py +56 -0
- pyappdist/wix/generate.py +118 -0
- pyappdist/wix/guid.py +24 -0
- pyappdist/wix/scan.py +44 -0
- pyappdist-0.1.0.dist-info/METADATA +97 -0
- pyappdist-0.1.0.dist-info/RECORD +29 -0
- pyappdist-0.1.0.dist-info/WHEEL +4 -0
- pyappdist-0.1.0.dist-info/entry_points.txt +3 -0
- pyappdist-0.1.0.dist-info/licenses/LICENSE +21 -0
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()}")
|