From 9d07157b3e3d9da6226b57e9d04281e3bf850cbd Mon Sep 17 00:00:00 2001 From: jay23606 Date: Sun, 8 Nov 2026 11:39:15 -0500 Subject: [PATCH] Linux AppImage fails with "No module named installer.gui" when the PyPI --- tests/release_linux_apprun_test.py | 57 ++++++++++++++++++++++++++++++ tools/make_release_linux.py | 2 ++ 2 files changed, 59 insertions(+) create mode 100644 tests/release_linux_apprun_test.py diff --git a/tests/release_linux_apprun_test.py b/tests/release_linux_apprun_test.py new file mode 100644 index 00000000..8aaa7c7f --- /dev/null +++ b/tests/release_linux_apprun_test.py @@ -0,0 +1,57 @@ +"""The AppImage entry must ignore the user's site-packages (issue 985). + +AppRun starts the bundled Python with `-m installer.gui`. Without PYTHONNOUSERSITE, a PyPI package named `installer` +in the user site shadows the project's own installer package and the window fails with "No module named installer.gui". + +The test reads the export lines from AppRun, then runs Python with that environment against a fake user site holding +a shadowing `installer` package (no `gui`) and a fake bundle holding the real layout. The bundle is added the way +totk-release.pth adds it, after the user site, so the user site wins unless AppRun turns it off. +""" +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / 'tools')) + +import make_release_linux # noqa: E402 + +line = 'export PYTHONNOUSERSITE=1' +lines = make_release_linux.APPRUN.splitlines() + +# Environment from AppRun's export lines. Values that refer to $HERE point into the mount and are not needed here. +env = dict(os.environ) +env.pop('PYTHONNOUSERSITE', None) +for text in lines: + if text.startswith('export ') and '=' in text and '$' not in text: + name, value = text[len('export '):].split('=', 1) + env[name] = value +env.pop('PYTHONPATH', None) + +with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + user_base = tmp / 'userbase' + env['PYTHONUSERBASE'] = str(user_base) + user_site = Path(subprocess.run( + [sys.executable, '-c', 'import site; print(site.getusersitepackages())'], + env=env, capture_output=True, text=True, check=True, cwd=tmp).stdout.strip()) + shadow = user_site / 'installer' + shadow.mkdir(parents=True) + (shadow / '__init__.py').write_text('# stands in for the PyPI "installer" package: no gui submodule\n') + + bundle = tmp / 'bundle' + (bundle / 'installer').mkdir(parents=True) + (bundle / 'installer' / '__init__.py').write_text('') + (bundle / 'installer' / 'gui.py').write_text('print("gui imported")\n') + + code = 'import site, sys; site.addsitedir(sys.argv[1]); import installer.gui' + result = subprocess.run([sys.executable, '-c', code, str(bundle)], + env=env, capture_output=True, text=True, cwd=tmp) + assert result.returncode == 0, f'installer.gui not importable with AppRun env: {result.stderr.strip()}' + +# Checked last so the import behaviour is what fails on the base commit; this line guards the mechanism. +assert line in lines, f'AppRun does not set {line!r}; AppRun lines: {lines}' + +print('AppRun sets PYTHONNOUSERSITE=1 and bundled installer.gui imports past a user-site shadow') diff --git a/tools/make_release_linux.py b/tools/make_release_linux.py index e3ba2182..10a62204 100644 --- a/tools/make_release_linux.py +++ b/tools/make_release_linux.py @@ -426,6 +426,8 @@ APPRUN = '''#!/bin/sh # The AppImage entry: the setup window, run by the bundled Python from the read-only mount (no bytecode written there). HERE="$(dirname "$(readlink -f "$0")")" export PYTHONDONTWRITEBYTECODE=1 +# Ignore ~/.local/lib/pythonX.Y/site-packages: a user-installed package such as PyPI's `installer` would shadow ours +export PYTHONNOUSERSITE=1 export TCL_LIBRARY="$HERE/python/lib/tcl9.0" export TK_LIBRARY="$HERE/python/lib/tk9.0" exec "$HERE/python/bin/python3" -m installer.gui "$@"