diff --git a/.github/workflows/build_windows_macos.yml b/.github/workflows/build_windows_macos.yml index 412e4a5da..bbe21a054 100644 --- a/.github/workflows/build_windows_macos.yml +++ b/.github/workflows/build_windows_macos.yml @@ -25,8 +25,7 @@ jobs: runs-on: 'windows-latest' strategy: matrix: - # An exact version keeps the packaged Windows artifact reproducible. - python-version: ['3.14.6'] + python-version: ['3.15.2'] outputs: hashes: ${{ steps.hash.outputs.hashes }} @@ -86,12 +85,27 @@ jobs: cd ardupilot_methodic_configurator copy ..\\windows\\ardupilot_methodic_configurator.spec pyinstaller --clean ardupilot_methodic_configurator.spec - if (-not (Test-Path "dist\\ardupilot_methodic_configurator\\_internal\\_tcl_data\\init.tcl") -or - -not (Test-Path "dist\\ardupilot_methodic_configurator\\_internal\\_tk_data\\tk.tcl")) { - throw "PyInstaller did not bundle the Tcl/Tk library data." + if ($LASTEXITCODE -ne 0) { + throw "PyInstaller failed with exit code $LASTEXITCODE." } del ardupilot_methodic_configurator.spec + - name: Smoke-test frozen Tcl/Tk initialization + shell: pwsh + timeout-minutes: 2 + run: | + $app = Join-Path $PWD "ardupilot_methodic_configurator/dist/ardupilot_methodic_configurator/ardupilot_methodic_configurator.exe" + Remove-Item Env:TCL_LIBRARY, Env:TK_LIBRARY -ErrorAction SilentlyContinue + Push-Location $env:RUNNER_TEMP + try { + & $app --validate-tkinter + if ($LASTEXITCODE -ne 0) { + throw "Frozen Tcl/Tk initialization failed with exit code $LASTEXITCODE." + } + } finally { + Pop-Location + } + - name: Verify frozen plugin and log-analysis modules run: python scripts/verify_packaged_modules.py ardupilot_methodic_configurator/build/ardupilot_methodic_configurator/PYZ-00.pyz @@ -208,7 +222,7 @@ jobs: runs-on: 'macos-latest' strategy: matrix: - python-version: ['3.13'] + python-version: ['3.15.2'] permissions: contents: write @@ -312,7 +326,7 @@ jobs: runs-on: 'ubuntu-22.04' # Use a consistent Linux environment for building the AppImage strategy: matrix: - python-version: ['3.14'] + python-version: ['3.15.2'] permissions: contents: write diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index b6e3efdc5..a10e8f38f 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -23,7 +23,7 @@ jobs: runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} strategy: matrix: - python-version: ["3.11", "3.14"] + python-version: ["3.11", "3.15.2"] steps: - name: Harden the runner (Audit all outbound calls) diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index b8862f975..862007f3d 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -23,7 +23,7 @@ jobs: runs-on: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} strategy: matrix: - python-version: ["3.11", "3.14"] + python-version: ["3.11", "3.15.2"] steps: - name: Harden the runner (Audit all outbound calls) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index dbb30e631..b61fef057 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -31,7 +31,7 @@ jobs: - ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} - windows-latest - macos-latest - python-version: ["3.14"] # to make sure the latest AMC supported python version does not break + python-version: ["3.15.2"] # to make sure the latest AMC supported python version does not break include: - os: ${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }} python-version: "3.11" # to make sure the minimum AMC supported python version does not break @@ -270,7 +270,7 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true - carryforward: "run-${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }}-py3.11,run-${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }}-py3.14,run-windows-latest-py3.14,run-macos-latest-py3.14" + carryforward: "run-${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }}-py3.11,run-${{ vars.UBUNTU_RUNNER || 'ubuntu-latest' }}-py3.15,run-windows-latest-py3.15,run-macos-latest-py3.15" # TODO: create a badge that presents the result of the Upload coverage xml report step diff --git a/ardupilot_methodic_configurator/__main__.py b/ardupilot_methodic_configurator/__main__.py index c82735992..8414a4516 100755 --- a/ardupilot_methodic_configurator/__main__.py +++ b/ardupilot_methodic_configurator/__main__.py @@ -31,6 +31,7 @@ from logging import warning as logging_warning from pathlib import Path from sys import exit as sys_exit +from tkinter import Tk, ttk import argcomplete @@ -131,6 +132,21 @@ def validate_plugin_registration() -> None: logging_info("Validated registration of all %d plugins", len(expected)) +def validate_tkinter_runtime() -> None: + """Verify Tcl, Tk, and ttk initialize using the runtime's bundled library data.""" + root = Tk() + try: + root.withdraw() + ttk.Button(root, text="Packaging validation").pack() + root.update_idletasks() + tcl_version = root.tk.call("info", "patchlevel") + tk_version = root.tk.call("package", "require", "Tk") + tcl_library = root.tk.call("info", "library") + print(f"Validated Tcl {tcl_version} and Tk {tk_version} (Tcl library: {tcl_library})") # noqa: T201 + finally: + root.destroy() + + class ApplicationState: # pylint: disable=too-few-public-methods """Data class to hold application state throughout the startup process.""" @@ -801,7 +817,10 @@ def parameter_editor_and_uploader(state: ApplicationState) -> None: def _run_validation_command() -> bool: - """Handle headless validation modes before normal application startup.""" + """Handle packaging validation modes before normal application startup.""" + if sys.argv[1:] == ["--validate-tkinter"]: + validate_tkinter_runtime() + return True if sys.argv[1:] == ["--validate-plugins"]: validate_plugin_registration() return True diff --git a/pyproject.toml b/pyproject.toml index 9704c170d..dea548a48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Topic :: Software Development :: Embedded Systems", "Topic :: System :: Installation/Setup", "Topic :: Scientific/Engineering", diff --git a/tests/test__main__.py b/tests/test__main__.py index 52489df3c..c63035c68 100755 --- a/tests/test__main__.py +++ b/tests/test__main__.py @@ -19,6 +19,7 @@ import sys import tempfile from pathlib import Path +from tkinter import TclError from types import ModuleType from unittest.mock import MagicMock, call, patch @@ -714,6 +715,40 @@ def test_bin_log_rejects_pseudo_vehicle_types_explicitly(self, vehicle_type: str parser_error.assert_called_once_with(f"--vehicle-type {vehicle_type} cannot be used together with --bin-log") + def test_tkinter_validator_initializes_gui_libraries_in_a_child_process(self) -> None: + """ + The packaging validator initializes Tk and ttk and exits without normal startup. + + GIVEN a Python environment with working Tcl/Tk libraries. + WHEN the application runs with --validate-tkinter. + THEN the process succeeds without starting the application workflow. + """ + result = subprocess.run( + [sys.executable, "-m", "ardupilot_methodic_configurator", "--validate-tkinter"], + capture_output=True, + check=False, + text=True, + timeout=20, + ) + + assert result.returncode == 0, result.stderr + + def test_tkinter_validator_rejects_unusable_gui_libraries(self) -> None: + """ + A missing Tcl/Tk runtime must fail validation rather than start the application. + + GIVEN Tcl/Tk library initialization fails. + WHEN the application runs with --validate-tkinter. + THEN the initialization error propagates and normal startup is never attempted. + """ + with ( + patch.object(amc_main.sys, "argv", ["amc.exe", "--validate-tkinter"]), + patch.object(amc_main, "Tk", side_effect=TclError("Missing Tcl/Tk library data")), + patch.object(amc_main, "create_argument_parser", side_effect=AssertionError("Must not start the application")), + pytest.raises(TclError, match="Missing Tcl/Tk library data"), + ): + main() + def test_frozen_bin_log_validator_dispatches_without_starting_the_gui(self) -> None: """The frozen validator command exits before normal application startup.""" with ( diff --git a/windows/ardupilot_methodic_configurator.spec b/windows/ardupilot_methodic_configurator.spec index 71cb0a5c6..2a1b7d7c8 100644 --- a/windows/ardupilot_methodic_configurator.spec +++ b/windows/ardupilot_methodic_configurator.spec @@ -4,28 +4,10 @@ from PyInstaller.utils.hooks import collect_submodules import certifi import os -from pathlib import Path -import sys -def _find_tcl_tk_library(directory: Path, prefix: str) -> Path: - """Return the Tcl/Tk library directory shipped with the build Python.""" - library_file = "init.tcl" if prefix == "tcl" else "tk.tcl" - candidates = sorted( - (path for path in directory.glob(f"{prefix}*") if path.is_dir() and (path / library_file).is_file()), - reverse=True, - ) - if not candidates: - raise SystemExit(f"Could not find {prefix} library data below {directory}") - return candidates[0] - - -# Set both variables before PyInstaller analyses tkinter so its built-in hook -# collects the library data into _tcl_data and _tk_data. Without this, the -# frozen application contains the DLLs but fails during the tkinter runtime hook. -tcl_root = Path(sys.base_prefix) / "tcl" -os.environ["TCL_LIBRARY"] = str(_find_tcl_tk_library(tcl_root, "tcl")) -os.environ["TK_LIBRARY"] = str(_find_tcl_tk_library(tcl_root, "tk")) +# Let PyInstaller's tkinter hook discover Tcl/Tk data. It supports both +# external script directories and Tcl/Tk 9 archives embedded in the DLLs. # Path to certifi's CA bundle certifi_cacert = certifi.where()