fix: isolate Windows build dependencies

- Ограничен поиск DLL при сборке каталогами Python, окружения и Windows.
- Добавлена проверка источников бинарных библиотек перед упаковкой.
- Добавлен режим --verify-bundle и регрессионный smoke-тест.
- Обновлены документация и версия приложения до 3.3.1.
This commit is contained in:
Данил Ярош 2026-09-18 14:32:19 +03:00
parent f8f6a216f5
commit 37fbb2c545
5 changed files with 74 additions and 1 deletions

View File

@ -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(

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -1,3 +1,3 @@
"""Текущая версия приложения; обновляется при публикации релиза."""
__version__ = "3.3.0"
__version__ = "3.3.1"