From 37fbb2c545ba76874ebfec3d6ecc5a2ae2f74296 Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Fri, 18 Sep 2026 14:32:19 +0300 Subject: [PATCH] fix: isolate Windows build dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ограничен поиск DLL при сборке каталогами Python, окружения и Windows. - Добавлена проверка источников бинарных библиотек перед упаковкой. - Добавлен режим --verify-bundle и регрессионный smoke-тест. - Обновлены документация и версия приложения до 3.3.1. --- LEDMapper.spec | 37 +++++++++++++++++++++++++++++++++++++ README.md | 2 ++ main.py | 3 +++ qa_smoke.py | 31 +++++++++++++++++++++++++++++++ version.py | 2 +- 5 files changed, 74 insertions(+), 1 deletion(-) diff --git a/LEDMapper.spec b/LEDMapper.spec index d66e9be..aaa0924 100644 --- a/LEDMapper.spec +++ b/LEDMapper.spec @@ -1,6 +1,8 @@ # -*- mode: python ; coding: utf-8 -*- +import os import subprocess +import sys from pathlib import Path from datetime import datetime @@ -45,7 +47,41 @@ def git_build_ref(): return 'untagged' +def _build_library_roots(): + windows_root = Path(os.environ.get('SystemRoot', r'C:\Windows')).resolve() + return tuple(dict.fromkeys( + path.resolve() for path in ( + Path(sys.executable).parent, + Path(sys.prefix), + Path(sys.base_prefix), + Path(sys.base_prefix) / 'DLLs', + windows_root / 'System32', + windows_root, + ) if path.is_dir() + )) + + +def _set_isolated_build_path(roots): + # Analysis searches PATH for DLLs. Unrelated tools may provide incompatible + # copies of the Windows runtime or ICU libraries there. + os.environ['PATH'] = os.pathsep.join(str(root) for root in roots) + + +def _check_binary_sources(binaries, roots): + foreign = [ + (name, source) + for name, source, kind in binaries + if kind == 'BINARY' + and not any(Path(source).resolve().is_relative_to(root) for root in roots) + ] + if foreign: + details = '\n'.join(f'{name}: {source}' for name, source in foreign[:10]) + raise RuntimeError(f'Build contains DLLs outside Python and Windows:\n{details}') + + app_name = f'LEDMapper-{git_build_ref()}' +library_roots = _build_library_roots() +_set_isolated_build_path(library_roots) build_version_path = spec_dir.parent / 'build' / 'ledmapper-build-version.txt' build_version_path.parent.mkdir(parents=True, exist_ok=True) build_version_path.write_text(app_name.removeprefix('LEDMapper-'), encoding='utf-8') @@ -79,6 +115,7 @@ a = Analysis( noarchive=False, optimize=0, ) +_check_binary_sources(a.binaries, library_roots) pyz = PYZ(a.pure) exe = EXE( diff --git a/README.md b/README.md index 93144a1..a1a55c1 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,8 @@ JSON схемы хранит `coordinate_unit`, `grid_step_mm` и `index_base: 0 .\.venv\Scripts\python.exe -m PyInstaller --noconfirm --clean app\LEDMapper.spec ``` +Сборочный `spec` ограничивает поиск DLL каталогами Python, виртуального окружения и Windows. Это защищает EXE от несовместимых системных библиотек сторонних программ, добавленных в `PATH`. После сборки проверяйте готовый файл командой `dist\LEDMapper-vX.Y.Z.exe --verify-bundle`: успешная проверка завершается с кодом `0` без открытия окна приложения. + В чистой сборке из релизного тега файл будет называться: ```text diff --git a/main.py b/main.py index e4b76b6..eed3b9e 100644 --- a/main.py +++ b/main.py @@ -26,6 +26,9 @@ except ImportError: def main() -> int: app = QApplication(sys.argv) + if "--verify-bundle" in sys.argv: + app.processEvents() + return 0 settings = AppSettings.load() apply_app_theme(settings.theme) icon_color = "#111827" if effective_theme(settings.theme) == "light" else "#e5e7eb" diff --git a/qa_smoke.py b/qa_smoke.py index c9495f2..1a91adb 100644 --- a/qa_smoke.py +++ b/qa_smoke.py @@ -264,6 +264,7 @@ def main() -> None: test_too_close_neighbor_has_lowest_priority, test_meter_like_coordinates_use_34_mm_threshold, test_pyinstaller_spec_build_ref_rules, + test_pyinstaller_spec_isolates_library_paths, ] for test in tests: gc.collect() @@ -4299,6 +4300,36 @@ def test_pyinstaller_spec_build_ref_rules() -> None: assert re.fullmatch(r"\d{6}-\d{8}", dirty_ref) +def test_pyinstaller_spec_isolates_library_paths() -> None: + spec_path = PROJECT_ROOT / "app" / "LEDMapper.spec" + spec_source = spec_path.read_text(encoding="utf-8") + helper_source = spec_source.split("app_name = f'LEDMapper-{git_build_ref()}'", 1)[0] + helpers = {"SPECPATH": str(spec_path.parent)} + exec(helper_source, helpers) + roots = helpers["_build_library_roots"]() + assert Path(sys.prefix).resolve() in roots + environment = helpers["os"].environ + original_path = environment.get("PATH", "") + try: + with tempfile.TemporaryDirectory() as temp_dir: + environment["PATH"] = temp_dir + helpers["os"].pathsep + original_path + helpers["_set_isolated_build_path"](roots) + assert temp_dir not in environment["PATH"].split(helpers["os"].pathsep) + helpers["_check_binary_sources"]( + [("valid.dll", str(Path(sys.prefix) / "valid.dll"), "BINARY")], roots + ) + try: + helpers["_check_binary_sources"]( + [("foreign.dll", str(Path(temp_dir) / "foreign.dll"), "BINARY")], roots + ) + except RuntimeError as exc: + assert "foreign.dll" in str(exc) + else: + raise AssertionError("Foreign DLL source must fail the build") + finally: + environment["PATH"] = original_path + + def path_has_segment(path, x1: float, y1: float, x2: float, y2: float) -> bool: # noqa: ANN001 for index in range(path.elementCount() - 1): first = path.elementAt(index) diff --git a/version.py b/version.py index c009da0..24fd45d 100644 --- a/version.py +++ b/version.py @@ -1,3 +1,3 @@ """Текущая версия приложения; обновляется при публикации релиза.""" -__version__ = "3.3.0" +__version__ = "3.3.1"