From 442d8bf1628aa90d80ea2de2dd7ff2ab5e8cac47 Mon Sep 17 00:00:00 2001 From: bilgituncay Date: Wed, 12 Aug 2026 21:47:48 +0200 Subject: [PATCH 1/2] Add PyInstaller freeze test for CI (#1125) --- .github/workflows/build-ubuntu-sdist.yml | 3 + .github/workflows/build-windows.yml | 4 ++ .github/workflows/run-ubuntu-checks.yml | 6 ++ test/pyinstaller_tags.py | 10 +++ test/pyinstaller_test.py | 85 ++++++++++++++++++++++++ 5 files changed, 108 insertions(+) create mode 100644 test/pyinstaller_tags.py create mode 100644 test/pyinstaller_test.py diff --git a/.github/workflows/build-ubuntu-sdist.yml b/.github/workflows/build-ubuntu-sdist.yml index fa99eaa94b..e0897b4882 100644 --- a/.github/workflows/build-ubuntu-sdist.yml +++ b/.github/workflows/build-ubuntu-sdist.yml @@ -62,6 +62,9 @@ jobs: python3 -m build --sdist --outdir dist . pip3 install dist/pygame_ce-*.tar.gz -vv + - name: Install PyInstaller + run: pip3 install pyinstaller + - name: Run tests env: SDL_VIDEODRIVER: "dummy" diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index d90e4b42b9..ea887b26db 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -208,6 +208,10 @@ jobs: shell: msys2 {0} run: python3 dev.py --ignore-dep ninja build --wheel /artifacts --lax + - name: Install PyInstaller + shell: msys2 {0} + run: pip install pyinstaller + - name: Run tests env: SDL_VIDEODRIVER: "dummy" diff --git a/.github/workflows/run-ubuntu-checks.yml b/.github/workflows/run-ubuntu-checks.yml index 1c487e5fff..fa2f0226b9 100644 --- a/.github/workflows/run-ubuntu-checks.yml +++ b/.github/workflows/run-ubuntu-checks.yml @@ -113,6 +113,12 @@ jobs: run: | pyenv global ${{ matrix.python }}-debug python dev.py build --lax --coverage --ctest --sanitize undefined + + - name: Install PyInstaller + run: | + pyenv global ${{ matrix.python }}-debug + pip install pyinstaller + - name: Run tests env: SDL_VIDEODRIVER: "dummy" diff --git a/test/pyinstaller_tags.py b/test/pyinstaller_tags.py new file mode 100644 index 0000000000..772bcb3fae --- /dev/null +++ b/test/pyinstaller_tags.py @@ -0,0 +1,10 @@ +__tags__ = [] + +exclude = False +try: + import PyInstaller +except ImportError: + exclude = True + +if exclude: + __tags__.extend(("ignore", "subprocess_ignore")) \ No newline at end of file diff --git a/test/pyinstaller_test.py b/test/pyinstaller_test.py new file mode 100644 index 0000000000..5c70e81136 --- /dev/null +++ b/test/pyinstaller_test.py @@ -0,0 +1,85 @@ +import os +import subprocess +import sys +import tempfile +import unittest + +import pygame +import pygame.examples + +class PyinstallerTest(unittest.TestCase): + """Tests that pygame-ce can be frozen into a standalone executable + using PyInstaller. + + Note: PyInstaller cannot see into pygame-ce's editable-install import + hook, so this test only reflects a normal (non-editable) install of + pygame-ce, matching how CI and end users actually install the package. + """ + + def test_pyinstaller_build_and_run(self): + """A minimal pygame-ce script can be frozen with PyInstaller and + the resulting executable runs successfully (exit code 0).""" + example_script = os.path.join( + os.path.dirname(os.path.abspath(pygame.examples.__file__)), + "headless_no_windows_needed.py", + ) + self.assertTrue( + os.path.isfile(example_script), + f"expected example script not found: {example_script}" + ) + + with tempfile.TemporaryDirectory() as tmp_dir: + dist_path = os.path.join(tmp_dir, "dist") + build_path = os.path.join(tmp_dir, "build") + spec_path = tmp_dir + + build_cmd = [ + sys.executable, + "-m", + "PyInstaller", + "--onefile", + "--distpath", + dist_path, + "--workpath", + build_path, + "--specpath", + spec_path, + example_script, + ] + + build_result = subprocess.run( + build_cmd, + timeout=120, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.assertEqual( + build_result.returncode, + 0, + f"PyInstaller build failed:\n{build_result.stderr.decode(errors='replace')}", + ) + + exe_name = "headless_no_windows_needed" + if sys.platform == "win32": + exe_name += ".exe" + exe_path = os.path.join(dist_path, exe_name) + + self.assertTrue( + os.path.isfile(exe_path), + f"expected frozen executable not found: {exe_path}", + ) + + run_result = subprocess.run( + [exe_path], + timeout=30, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.assertEqual( + run_result.returncode, + 0, + f"frozen executable exited nonzero:\n{run_result.stderr.decode(errors='replace')}", + ) + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 44031d318d7529e3550fbb8cf789ed45e52a89ca Mon Sep 17 00:00:00 2001 From: bilgituncay Date: Thu, 13 Aug 2026 13:19:20 +0200 Subject: [PATCH 2/2] Fix formatting and skip test on editable installs --- test/pyinstaller_tags.py | 2 +- test/pyinstaller_test.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/test/pyinstaller_tags.py b/test/pyinstaller_tags.py index 772bcb3fae..c7a8fd01a2 100644 --- a/test/pyinstaller_tags.py +++ b/test/pyinstaller_tags.py @@ -7,4 +7,4 @@ exclude = True if exclude: - __tags__.extend(("ignore", "subprocess_ignore")) \ No newline at end of file + __tags__.extend(("ignore", "subprocess_ignore")) diff --git a/test/pyinstaller_test.py b/test/pyinstaller_test.py index 5c70e81136..a5548baf2e 100644 --- a/test/pyinstaller_test.py +++ b/test/pyinstaller_test.py @@ -7,6 +7,7 @@ import pygame import pygame.examples + class PyinstallerTest(unittest.TestCase): """Tests that pygame-ce can be frozen into a standalone executable using PyInstaller. @@ -19,13 +20,19 @@ class PyinstallerTest(unittest.TestCase): def test_pyinstaller_build_and_run(self): """A minimal pygame-ce script can be frozen with PyInstaller and the resulting executable runs successfully (exit code 0).""" + if any(name.endswith("_editable_loader") for name in sys.modules): + self.skipTest( + "pygame-ce is installed in editable mode; PyInstaller cannot " + "see through the editable-install import hook to bundle it" + ) + example_script = os.path.join( os.path.dirname(os.path.abspath(pygame.examples.__file__)), "headless_no_windows_needed.py", ) self.assertTrue( os.path.isfile(example_script), - f"expected example script not found: {example_script}" + f"expected example script not found: {example_script}", ) with tempfile.TemporaryDirectory() as tmp_dir: @@ -81,5 +88,6 @@ def test_pyinstaller_build_and_run(self): f"frozen executable exited nonzero:\n{run_result.stderr.decode(errors='replace')}", ) + if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main()