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.
- pyappdist-0.1.0/LICENSE +21 -0
- pyappdist-0.1.0/PKG-INFO +97 -0
- pyappdist-0.1.0/README.md +65 -0
- pyappdist-0.1.0/pyproject.toml +53 -0
- pyappdist-0.1.0/src/pyappdist/__init__.py +0 -0
- pyappdist-0.1.0/src/pyappdist/_hostexec.py +30 -0
- pyappdist-0.1.0/src/pyappdist/cli.py +199 -0
- pyappdist-0.1.0/src/pyappdist/config.py +176 -0
- pyappdist-0.1.0/src/pyappdist/context.py +30 -0
- pyappdist-0.1.0/src/pyappdist/deps.py +104 -0
- pyappdist-0.1.0/src/pyappdist/errors.py +15 -0
- pyappdist-0.1.0/src/pyappdist/image/__init__.py +37 -0
- pyappdist-0.1.0/src/pyappdist/image/assemble.py +30 -0
- pyappdist-0.1.0/src/pyappdist/image/compile.py +32 -0
- pyappdist-0.1.0/src/pyappdist/image/install.py +37 -0
- pyappdist-0.1.0/src/pyappdist/image/layout.py +35 -0
- pyappdist-0.1.0/src/pyappdist/launcher/__init__.py +7 -0
- pyappdist-0.1.0/src/pyappdist/launcher/build.py +211 -0
- pyappdist-0.1.0/src/pyappdist/resources/launcher.c +152 -0
- pyappdist-0.1.0/src/pyappdist/runtime.py +259 -0
- pyappdist-0.1.0/src/pyappdist/sign.py +42 -0
- pyappdist-0.1.0/src/pyappdist/targets.py +46 -0
- pyappdist-0.1.0/src/pyappdist/wheels.py +92 -0
- pyappdist-0.1.0/src/pyappdist/wix/__init__.py +15 -0
- pyappdist-0.1.0/src/pyappdist/wix/build.py +56 -0
- pyappdist-0.1.0/src/pyappdist/wix/generate.py +118 -0
- pyappdist-0.1.0/src/pyappdist/wix/guid.py +24 -0
- pyappdist-0.1.0/src/pyappdist/wix/scan.py +44 -0
pyappdist-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pyappdist-0.1.0/PKG-INFO
ADDED
|
@@ -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"
|