Tutorial: installers from scratch

This walkthrough builds a real Windows .msi installer for a tiny tkinter GUI, starting from an empty directory, then ships the very same project as a Linux .run installer and a macOS .dmg. It follows the path pyappdist itself takes: scaffold a proper package, confirm it builds a wheel, confirm its launcher command runs from that wheel, then add the pyappdist config and build the installers.

tkinter is used on purpose: it is part of the Python standard library and ships with the bundled python-build-standalone runtime, so the app needs no third-party dependencies — keeping the focus on packaging.

Note

The final MSI step needs the Windows toolchain (MSVC + WiX). Run the build on Windows — see Windows — MSI installer. Steps 1–4 are plain Python and work on any OS.

Step 1 — Scaffold a package with uv

pyappdist packages an installable project, so start from a packaged layout rather than a loose script. uv init --package creates exactly that — a src/ package, a [build-system], and a console-script entry point:

uv init --package hellotk
cd hellotk

The --package flag matters: it makes the project itself installable, so the package definition states exactly which sources pyappdist installs — nothing is picked up loosely from the working directory.

You get a pyproject.toml like this (note the [project.scripts] entry — hellotk = "hellotk:main" — which is the same module:callable form pyappdist’s launcher uses):

[project]
name = "hellotk"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = []

[project.scripts]
hellotk = "hellotk:main"

[build-system]
requires = ["uv_build>=0.8.17,<0.9.0"]
build-backend = "uv_build"

and a stub src/hellotk/__init__.py with a main() function.

Step 2 — Write the tkinter “hello world”

Replace src/hellotk/__init__.py with a small windowed app. Keep the entry point a plain function named main that takes no arguments and returns an exit code — that is exactly what the launcher will call:

import tkinter as tk


def main() -> int:
    root = tk.Tk()
    root.title("Hello Tk")
    root.geometry("320x160")
    tk.Label(root, text="Hello from pyappdist!", font=("Segoe UI", 16)).pack(expand=True)
    tk.Button(root, text="Quit", command=root.destroy).pack(pady=12)
    root.mainloop()
    return 0

Run it the normal way while developing:

uv run hellotk

A window should appear. (tkinter is included in the standard python.org Windows installer; if import tkinter fails locally, install/repair Python with the tcl/tk option. This does not affect the built app — the bundled runtime already includes tkinter.)

Step 3 — Test that the project builds a wheel

pyappdist installs your app the way pip would, so the first requirement is that the project builds a wheel. Confirm it:

uv build --wheel

This must succeed and drop a .whl in dist/:

Successfully built dist/hellotk-0.1.0-py3-none-any.whl

(The equivalent without uv is python -m pip wheel --no-deps ..) If this fails, fix the packaging before going further — pyappdist cannot distribute an app that does not build a wheel. See What your project must satisfy.

Step 4 — Test the launcher command from the installed wheel

The second requirement is that the launcher’s entry point runs against the installed package — not a source file on disk. The launcher will invoke your "hellotk:main" entry in this exact form:

python -I -c "import sys; from hellotk import main; sys.exit(main())"

(-I isolates the app from the user’s environment, and the sys.exit is what turns main()’s return value into the process exit code.) Verify the essential part — that the callable imports and runs from the installed wheel — the same way pyappdist will, in a throwaway environment built from the wheel:

python -m venv ../check
../check/bin/python -m pip install dist/hellotk-0.1.0-py3-none-any.whl   # Windows: ..\check\Scripts\python.exe
../check/bin/python -c "from hellotk import main; main()"

If the window opens from that clean environment, the launcher will work. If it only ran under uv run (because it depended on files in your checkout that the wheel doesn’t include), fix the packaging now.

Tip

The other entry form is python -m <module>: set entry = "hellotk.cli" (no colon) for an app whose startup lives under if __name__ == "__main__":. This tutorial uses the module:callable form.

Step 5 — Add the pyappdist configuration

Add pyappdist as a dev dependency:

uv add --dev pyappdist

Then add a [tool.pyappdist] section to pyproject.toml. One launcher (gui = true so it starts via pythonw.exe with no console window — right for a GUI), and one Windows MSI target:

[tool.pyappdist]
name = "Hello Tk"
python = "3.12"                        # bundled runtime; must satisfy requires-python

launchers = [
  { name = "hellotk", entry = "hellotk:main", gui = true },
]

[[tool.pyappdist.targets]]
name = "windows"
platform = "windows-x86_64"
format = "msi"
manufacturer = "Example Inc."
# upgrade-code = "..."    # auto-generated and written back if omitted

The entry is the very command you tested in step 4, and gui = true is what makes the console window disappear. See Configuration for every key and Windows — MSI installer for the MSI-specific options.

Replace ``python = “3.12”`` with the runtime version you want to ship. It must satisfy your [project] requires-python (>=3.11 in step 1), so pick a version equal to or newer than that floor — never older.

Step 6 — Build the installer

Note

This step needs the Windows toolchain: MSVC C++ build tools (to compile the launcher .exe) and WiX v5 (to build the MSI). If you don’t have them yet, follow the winget install steps in Build requirements — the build-only Build Tools (no full Visual Studio IDE) are enough.

Build the Windows target:

uv run pyappdist build windows

pyappdist runs the full pipeline — exports your locked dependencies (none here), builds wheels with the target runtime, installs them into a fresh python-build-standalone runtime, compiles the hellotk.exe launcher, and packs it all with WiX. The artifact lands under appdist/windows/dist/:

appdist/windows/dist/hellotk-0.1.0.msi             # the installer

Double-click the .msi to install (a per-user install needs no admin rights); hellotk then appears in the Start menu and launches your tkinter window — with its own private Python runtime, independent of any Python on the machine.

Step 7 — Confirm the upgrade-code

The upgrade-code is the stable identity of your product across versions. An MSI uses it (via WiX MajorUpgrade) to recognize an installed copy and replace it in place when the user installs a newer version, instead of leaving two copies side by side. It must therefore stay the same for the entire life of the product — only the version number changes between releases.

You left it out of the config in step 5, so on this first build pyappdist generated a UUID and wrote it back into your pyproject.toml. Open the file and you will now find a concrete value where the comment used to be:

[[tool.pyappdist.targets]]
name = "windows"
platform = "windows-x86_64"
format = "msi"
manufacturer = "Example Inc."
upgrade-code = "6f6c2d6e-1b2a-5c3d-8e4f-9a0b1c2d3e4f"   # generated on the first build

Commit this value and keep it: build the same project again and the line stays unchanged, so every release shares one upgrade-code and upgrades cleanly. (If you prefer to pick the GUID yourself, set upgrade-code before the first build and pyappdist will leave it alone.) See Windows — MSI installer for the full upgrade behavior.

Step 8 — Add a Linux .run installer

The same project can ship to other platforms: each output package is one more [[tool.pyappdist.targets]] entry in the same pyproject.toml. Add a Linux target next to the Windows one:

[[tool.pyappdist.targets]]
name = "linux"
platform = "linux-x86_64"
format = "linux"

No toolchain is needed this time — Linux launchers are relocatable shell wrappers, so no compiler and no WiX are involved. Build it on Linux:

uv run pyappdist build linux

Now that the project defines more than one target, pyappdist build requires the target name — which is why the commands here say windows and linux explicitly. The artifact lands in appdist/linux/dist/:

appdist/linux/dist/hellotk-0.1.0-linux.run      # self-extracting installer

The .run file is the installer: per-user, no root required. It copies the app under ~/.local, symlinks the launcher into ~/.local/bin (so typing hellotk in a terminal opens the window), and drops an uninstall.sh:

$ ./hellotk-0.1.0-linux.run               # install into ~/.local
$ hellotk                                 # run it
$ ./hellotk-0.1.0-linux.run --uninstall   # remove it

A .desktop menu entry is written only for launchers that define an icon (there, gui = true sets Terminal=false so the app opens without a terminal); this tutorial’s launcher has none, so the app is started from the terminal. See Linux — .run installer for --prefix, the compression choices, and the categories key.

Step 9 — Add a macOS .dmg

A GUI app on macOS ships as a drag-to-/Applications .app bundle, usually inside a .dmg disk image — that is format = "dmg". It needs one new app-level key: identifier, the reverse-DNS bundle identifier every macOS app must carry. Add it to [tool.pyappdist], then add the target:

[tool.pyappdist]
name = "Hello Tk"
python = "3.12"
identifier = "com.example.hellotk"   # required for macapp/dmg

[[tool.pyappdist.targets]]
name = "macos"
platform = "macos-aarch64"           # Apple Silicon; "macos-x86_64" for Intel
format = "dmg"

The .app/.dmg build is native-only: run it on a Mac with the Xcode command-line tools installed (xcode-select --install provides clang, codesign, and hdiutil):

uv run pyappdist build macos

The result is a disk image in appdist/macos/dist/:

appdist/macos/dist/hellotk-0.1.0.dmg

Mount it and drag Hello Tk.app into /Applications. The bundle is named after the app-level name, carries its own Python runtime under Contents/Resources/python, and gets a generated placeholder icon — set icon = { macos = "..." } on the launcher to supply a real .png.

One caveat before distributing it: without a configured signing identity the bundle is ad-hoc signed — it runs on the build machine, but Gatekeeper rejects it on anyone else’s Mac. To pass Gatekeeper, set signing-identity and notary-profile on the target so the build signs with your Developer ID and notarizes the .dmg; the one-time Apple setup and the exact keys are in Code signing and notarization.

Where to go next