For LLMs and coding agents
This documentation is written for two readers on purpose: a human
skimming for what to run, and an agent that needs the deeper “why”
before touching the code. Splitting them keeps the rest of the docs
short instead of cramming both into every page. This page is the second
half - a dense reference, not a tutorial. Human readers can stop here;
nothing on this page is needed to use appimage.
Module map
appimage/
├── __init__.py __version__
├── appstarter.py runtime module: entry-point dispatch, --python-* flags, venv activation
└── ctl/ the appimagectl build tool
├── __main__.py argparse CLI, subcommand dispatch, top-level exception handling
├── _base.py BuildConfig (raw pyproject.toml fields), _resolve() → _ResolvedBuild,
│ all config-level warnings/errors (reproducible-requires-pins, etc.)
├── _download.py _download, _verify_sha256, _require_or_warn_unverified,
│ _resolution_source (the shared "config → cache → download" classifier)
├── _appimagetool.py resolve/verify appimagetool + the runtime stub; classic-build detection
├── _python.py resolve/verify the python-build-standalone tarball; python_dir bypass
├── _toml.py small pyproject.toml read/write helpers
├── build_appdir.py AppDir assembly: install, hooks, bytecode, build-path scrubbing
├── build.py packaging: resolves appimagetool/runtime, invokes it, zsync check
├── check.py `check` subcommand + the shared _format_check() used by check/build/
│ build-appdir/init/update-tools alike
├── init.py `init` subcommand: writes auto-detected + resolved pins
├── lock.py `lock` subcommand: pylock.toml + build_pylock generation
├── enable_reproducible.py `enable-reproducible`: init + lock + verify build + flip the flag
├── update_tools.py `update-tools` subcommand
└── templates/ AppRun.sh, desktop.template
appimage.ctl.__init__ only re-exports the public API; __main__.py’s
imports come from there, not from the individual modules directly.
Invariants - do not reintroduce these
Every one of these was a real, shipped bug, found and fixed by actually running the tool end to end (not just via mocked unit tests). If you’re touching adjacent code, check you haven’t reopened one of these:
appimagetool/runtime-file resolution never searches
PATH. Only explicit config path → build cache → download, for both. This was changed deliberately (see Classic appimagetool detected) - a stray classic AppImageKit build onPATHwas silently picked up on a real machine before this. Don’t add ashutil.which(...)fallback back in for either.A resolved appimagetool binary is checked against
_looks_like_classic_appimagekit()before use, unless it was just downloaded this run (by definition from the right source). Two independent signals: build-path debug strings (_APPIMAGEKIT_BUILD_PATH_MARKERS) and the--versionbanner’s wording ("(commit "vs"(git version "). Neither is airtight alone; that’s why a match aborts the build rather than being logged and ignored -appimagetool_sha256alone can’t catch this, since it only proves the same (possibly wrong) file is used every time.Console-script shim relocation must handle both the quoted and unquoted executable form.
pip._vendor.distlib.scripts. enquote_executablewraps the embedded interpreter path in"..."whenever it contains a space, and a space also forces the two-line#!/bin/shfallback regardless of length._relocate_console_scripttries the executable both bare and"quoted", for both the one-line and two-line shebang shapes. A self-locating replacement (embedding$(dirname ...)) must always be written in the two-line#!/bin/sh+execform - the kernel’s#!handling never shell-expands a one-line shebang, so a literal one-line#!"$(dirname ...)/python3"fails at exec time with “bad interpreter”, even unmoved.--runtime-file’s value is staged to a plain-ASCII temp path before invoking appimagetool (_stage_runtime_file_for_appimagetoolinbuild.py). The pinnedAppImage/appimagetoolbuild’s own glib-based CLI option parser fails to decode a non-ASCII byte in that flag’s value specifically - confirmed by isolating the failure outside appimagectl entirely, independent of locale (de_DE.UTF-8,C.UTF-8both reproduce it) and ofG_FILENAME_ENCODING/G_BROKEN_FILENAMES. Positional arguments (the AppDir path, the output filename) are unaffected - only this one flag. A project path containing any non-ASCII character (an ordinary thing - e.g. a home directory under a non-English username) would otherwise fail packaging 100% of the time.Every subprocess that runs the bundled interpreter for an install gets
PYTHONNOUSERSITE=1(_isolated_subprocess_envinbuild_appdir.py), includingpost_install/pre_packagehooks andpip lock. Without it, pip additionally resolves against the build user’s own~/.local/lib/pythonX.Y/site-packages(PEP 370) - if a requirement happens to already be satisfied there, pip silently skips installing it into the AppDir at all (“Requirement already satisfied” instead of “Collecting”), and the built AppImage fails at runtime withModuleNotFoundErroron any other host, while the build itself reports success.The
pylock.tomldependency install does not pass--no-deps. pip’s hash-checking mode, once triggered by any--hashin the requirement set, demands every requirement in that invocation carry one - so leaving normal dependency resolution on means a stale or incomplete lock aborts the build loudly (no hash-matching candidate for a missing transitive dependency) instead of silently shipping an AppDir that’s missing it. The local project install (pip install --no-deps .[extras]) keeps--no-deps- that one’s deliberately excluded from the lock and must not reach past its own listed dependencies.pylockgeneration does not usepip lock --only-deps. That flag excludes every given requirement from its own output, not a chosen one (“No user-supplied requirements will be handled, even if they were dependencies of other user-supplied requirements” - pip’s own--help). Using it would silently drop theappimageruntime module’s own pin (and anypackagesentries) frompylock.toml, leaving only their transitive dependencies locked - a real, shipped bug (“No module named appimage” at AppImage startup).lockinstead resolves everything together and strips just the local project’s entry afterwards, identified structurally by its local directory source rather than by name.build_pylockis applied viapip install --build-constraint, not--no-build-isolation. The--no-build-isolationapproach installed the build backend into the main interpreter and reused it - left the backend permanently installed in the shipped AppImage, and its import-time bytecode caches carried install-time timestamps that broke byte-for-byte reproducibility across independent builds.--build-constraintkeeps pip’s own isolated, throwaway build environment, just hash-verifies what goes into it._scrub_build_pathsreads each package’s ownRECORDfile to find what to scrub, rather than guessing at pip’s console-script format - that format has more than one variant (a plain interpreter, or distlib’s#!/bin/shpolyglot fallback) and guessing would silently stop working on a pip version that changes it.direct_url.jsonis always deleted (meaningless once the AppDir runs somewhere else); console-script shims are relocated in place instead of deleted, with a narrow, exact-byte-match rewrite - anything that doesn’t match a known distlib shim format falls back to deletion rather than risking a script that’s broken in a new way (the lesson from virtualenv’s old--relocatable, eventually removed upstream for exactly this unreliability)._resolution_source(explicit, cache_path)is the only place that decides “config path, then build cache, then download.” Every resolver (_locate_appimagetool,_resolve_runtime_file,_resolve_python_tarball) calls it, and so doescheck’s_predict_unverified_downloads- which is howcheck/build/build-appdircan predict averify_downloadsfailure without downloading or hashing anything, without a second, driftable copy of the precedence logic. Don’t reintroduce a localif explicit: ... elif cache.exists(): ...anywhere this matters.build_appdir()never resolves appimagetool or the runtime file - by design. AppDir assembly must work completely independently of appimagetool; only the fullbuild()(default subcommand) needs both.check’sappdir_errors/package_errorssplit, and_predict_unverified_downloads’s python-archive-vs-appimagetool/ runtime-file bucketing, both preserve this split deliberately - don’t merge them.__main__.py’s exception handling wraps the whole dispatch, includingBuildConfig.from_pyproject(), in onetry/except (FileNotFoundError, RuntimeError, OSError, ValueError, subprocess.CalledProcessError). A previously separate, narrowerexcept FileNotFoundErrorjust around config loading meant a malformedpyproject.toml(tomllib.TOMLDecodeError, aValueErrorsubclass) produced a raw traceback instead of a cleanError: ...line - keep it as one block.
Testing changes end to end, not just via mocked unit tests
Several of the bugs above only surfaced from a real build (network
downloads, real pip install, real appimagetool subprocess) - the
mocked unit test suite passed the whole time. When changing anything in
_appimagetool.py, build_appdir.py, or build.py, prefer verifying
against a real build of examples/myapp/ over trusting mocks alone; see
Development for the exact commands
(hatch run appimagectl --project-dir examples/myapp, then the
build-twice-and-diff pattern for anything touching output determinism).
Directory-shape variations are a specific, previously-bug-prone axis worth
checking deliberately: a build path containing a space or a non-ASCII
character, a very short or very long/deeply nested path, a renamed
build_dir/dist_dir, a build invoked through a symlink - all of these
are expected to produce byte-identical output to a plain path; two of
them (space, non-ASCII) didn’t, until fixed.
Where the narrative detail lives
This page intentionally doesn’t repeat the full explanations - each invariant above links to (or is a compressed version of) fuller prose elsewhere:
Reproducible builds - the six independent fixes behind byte-identical output, the classic-appimagetool check, the zsync/
PATHstory, dependency and build-backend hash-pinning.How AppImages are built - the manual, step-by-step equivalent of what
appimage.ctlautomates.CLI Reference / Configuration - every flag and config key, kept intentionally terse; this page is where the “why” that used to live in those tables moved to.
Development - environment setup, running the test suite and lint, building an AppImage from a working copy.