pyappdist 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Atsuo Ishimoto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyappdist
3
+ Version: 0.1.0
4
+ Summary: Turn a Python app into a Windows installer(msi)
5
+ Keywords: windows,installer,msi,packaging,distribution,wheel,wix
6
+ Author: Atsuo Ishimoto
7
+ Author-email: Atsuo Ishimoto <atsuoishimoto@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Environment :: Win32 (MS Windows)
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Build Tools
20
+ Classifier: Topic :: System :: Installation/Setup
21
+ Classifier: Topic :: System :: Software Distribution
22
+ Requires-Dist: packaging>=23
23
+ Requires-Dist: pip>=26.1.1
24
+ Requires-Dist: tomlkit>=0.15.0
25
+ Requires-Dist: wheel>=0.47.0
26
+ Requires-Python: >=3.11
27
+ Project-URL: Homepage, https://github.com/atsuoishimoto/pyappdist
28
+ Project-URL: Documentation, https://pyappdist.readthedocs.io/
29
+ Project-URL: Repository, https://github.com/atsuoishimoto/pyappdist
30
+ Project-URL: Issues, https://github.com/atsuoishimoto/pyappdist/issues
31
+ Description-Content-Type: text/markdown
32
+
33
+ # pyappdist
34
+
35
+ **Turn a Python app into a Windows installer — and it just works.**
36
+
37
+ > ⚠️ **Alpha.** pyappdist is under active development. It works end-to-end today, but the
38
+ > config schema, CLI, and output layout may still change without notice.
39
+
40
+ pyappdist does **not** freeze your code. Instead of bundling Python and your app into a
41
+ single executable (and fighting hidden imports, data files, and plugins along the way),
42
+ it installs your app into a real, dedicated Python runtime — exactly the way `pip` would —
43
+ and ships that.
44
+
45
+ Because the runtime is a normal Python environment, **most apps run as-is**: no hooks, no
46
+ `--hidden-import`, no `--add-data`, no per-library workarounds. If it runs under `uv run`,
47
+ it almost certainly runs after `pyappdist build`. C extensions, `abi3` wheels, Qt plugins,
48
+ and tkinter-based GUIs work unmodified because the install layout is real.
49
+
50
+ ## Quick start
51
+
52
+ Add a `[tool.pyappdist]` section to your app's `pyproject.toml`:
53
+
54
+ ```toml
55
+ [tool.pyappdist]
56
+ name = "My App"
57
+ python = "3.12"
58
+ target = "windows-x86_64"
59
+
60
+ [[tool.pyappdist.launchers]]
61
+ name = "myapp" # produces myapp.exe
62
+ entry = "myapp:main" # module:callable
63
+
64
+ [tool.pyappdist.wix]
65
+ manufacturer = "Example Inc."
66
+ ```
67
+
68
+ Then add pyappdist and build:
69
+
70
+ ```bash
71
+ uv add --dev pyappdist
72
+ uv run pyappdist build . # wheels -> runtime -> image -> launcher -> wix -> MSI
73
+ ```
74
+
75
+ The result lands under `appdist/dist/`: a portable `.zip` and an `.msi` installer.
76
+
77
+ ## Documentation
78
+
79
+ Full documentation lives in the [`docs/`](docs/) directory (Sphinx / Read the Docs):
80
+
81
+ - [Installation & requirements](docs/installation.rst)
82
+ - [Quick start](docs/quickstart.rst)
83
+ - [How it works](docs/how-it-works.rst)
84
+ - [Configuration reference](docs/configuration.rst)
85
+ - [CLI reference](docs/cli.rst)
86
+ - [Dependency resolution](docs/dependencies.rst)
87
+ - [Code signing](docs/signing.rst)
88
+ - [Samples](docs/samples.rst)
89
+
90
+ ## Status
91
+
92
+ **Alpha** — the pipeline works end-to-end, but expect breaking changes to the config
93
+ schema, CLI, and output layout as it matures.
94
+
95
+ Windows x64 is the current target. macOS/Linux packaging, auto-update, and code-signing
96
+ certificates are out of scope for now. Distributed apps are not obfuscated, and unsigned
97
+ installers will trigger a SmartScreen warning.
@@ -0,0 +1,65 @@
1
+ # pyappdist
2
+
3
+ **Turn a Python app into a Windows installer — and it just works.**
4
+
5
+ > ⚠️ **Alpha.** pyappdist is under active development. It works end-to-end today, but the
6
+ > config schema, CLI, and output layout may still change without notice.
7
+
8
+ pyappdist does **not** freeze your code. Instead of bundling Python and your app into a
9
+ single executable (and fighting hidden imports, data files, and plugins along the way),
10
+ it installs your app into a real, dedicated Python runtime — exactly the way `pip` would —
11
+ and ships that.
12
+
13
+ Because the runtime is a normal Python environment, **most apps run as-is**: no hooks, no
14
+ `--hidden-import`, no `--add-data`, no per-library workarounds. If it runs under `uv run`,
15
+ it almost certainly runs after `pyappdist build`. C extensions, `abi3` wheels, Qt plugins,
16
+ and tkinter-based GUIs work unmodified because the install layout is real.
17
+
18
+ ## Quick start
19
+
20
+ Add a `[tool.pyappdist]` section to your app's `pyproject.toml`:
21
+
22
+ ```toml
23
+ [tool.pyappdist]
24
+ name = "My App"
25
+ python = "3.12"
26
+ target = "windows-x86_64"
27
+
28
+ [[tool.pyappdist.launchers]]
29
+ name = "myapp" # produces myapp.exe
30
+ entry = "myapp:main" # module:callable
31
+
32
+ [tool.pyappdist.wix]
33
+ manufacturer = "Example Inc."
34
+ ```
35
+
36
+ Then add pyappdist and build:
37
+
38
+ ```bash
39
+ uv add --dev pyappdist
40
+ uv run pyappdist build . # wheels -> runtime -> image -> launcher -> wix -> MSI
41
+ ```
42
+
43
+ The result lands under `appdist/dist/`: a portable `.zip` and an `.msi` installer.
44
+
45
+ ## Documentation
46
+
47
+ Full documentation lives in the [`docs/`](docs/) directory (Sphinx / Read the Docs):
48
+
49
+ - [Installation & requirements](docs/installation.rst)
50
+ - [Quick start](docs/quickstart.rst)
51
+ - [How it works](docs/how-it-works.rst)
52
+ - [Configuration reference](docs/configuration.rst)
53
+ - [CLI reference](docs/cli.rst)
54
+ - [Dependency resolution](docs/dependencies.rst)
55
+ - [Code signing](docs/signing.rst)
56
+ - [Samples](docs/samples.rst)
57
+
58
+ ## Status
59
+
60
+ **Alpha** — the pipeline works end-to-end, but expect breaking changes to the config
61
+ schema, CLI, and output layout as it matures.
62
+
63
+ Windows x64 is the current target. macOS/Linux packaging, auto-update, and code-signing
64
+ certificates are out of scope for now. Distributed apps are not obfuscated, and unsigned
65
+ installers will trigger a SmartScreen warning.
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "pyappdist"
3
+ version = "0.1.0"
4
+ description = "Turn a Python app into a Windows installer(msi)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ { name = "Atsuo Ishimoto", email = "atsuoishimoto@gmail.com" },
11
+ ]
12
+ keywords = ["windows", "installer", "msi", "packaging", "distribution", "wheel", "wix"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Environment :: Win32 (MS Windows)",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: Microsoft :: Windows",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Build Tools",
24
+ "Topic :: System :: Installation/Setup",
25
+ "Topic :: System :: Software Distribution",
26
+ ]
27
+ dependencies = [
28
+ "packaging>=23",
29
+ "pip>=26.1.1",
30
+ "tomlkit>=0.15.0",
31
+ "wheel>=0.47.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/atsuoishimoto/pyappdist"
36
+ Documentation = "https://pyappdist.readthedocs.io/"
37
+ Repository = "https://github.com/atsuoishimoto/pyappdist"
38
+ Issues = "https://github.com/atsuoishimoto/pyappdist/issues"
39
+
40
+ [project.scripts]
41
+ pyappdist = "pyappdist.cli:main"
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "pytest>=8",
46
+ ]
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+
51
+ [build-system]
52
+ requires = ["uv_build>=0.11.6,<0.12.0"]
53
+ build-backend = "uv_build"
File without changes
@@ -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)
@@ -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())
@@ -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
+ )
@@ -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"