""" Установка зависимостей из локального каталога колёс (офлайн). OTA-пакет **всегда** содержит полный ``vendor/wheels``; на клиенте при обновлении PyPI не используется (``WESP_OTA_UPDATE=1`` / ``offline_only=True``). Сборка релиза (на машине той же arch, что терминалы в проде — x86 или ARM): ./scripts/build_release.sh X.Y.Z Разные архитектуры — разные Gitea-репозитории и разные ZIP. Prod (venv отдельно от кода, напр. ``/opt/wes``): WESP_VENV=/opt/wes # или WESP_PYTHON=/opt/wes/bin/python Отладка с PyPI (не для поля): WESP_PIP_ALLOW_INDEX=1 python install_deps.py """ from __future__ import annotations import os import platform import re import shlex import subprocess import sys import tempfile from pathlib import Path from typing import List, Optional, Union ROOT = Path(__file__).resolve().parent REQ_FILE = ROOT / "requirements-prod.txt" DEFAULT_WHEELHOUSE = ROOT / "vendor" / "wheels" RPI_ONLY_PACKAGES = { "rpi.gpio", "hx711", "hx711py", } class UpdateDepsError(Exception): """Ошибка установки зависимостей при OTA (без fallback на PyPI).""" def is_arm() -> bool: machine = platform.machine().lower() return any(part in machine for part in ("arm", "aarch64")) def project_root(root: Optional[Union[str, Path]] = None) -> Path: if root is not None: return Path(root).resolve() return ROOT def wheelhouse_dir(root: Optional[Union[str, Path]] = None) -> Path: raw = os.environ.get("WESP_VENDOR_WHEELS", "").strip() if raw: return Path(raw).expanduser().resolve() return project_root(root) / "vendor" / "wheels" def wheelhouse_has_packages(d: Path) -> bool: if not d.is_dir(): return False for pattern in ("*.whl", "*.tar.gz", "*.zip"): if any(d.glob(pattern)): return True return False def allow_pypi() -> bool: if os.environ.get("WESP_OTA_UPDATE", "").strip().lower() in ("1", "true", "yes", "on"): return False return os.environ.get("WESP_PIP_ALLOW_INDEX", "").strip().lower() in ( "1", "true", "yes", "on", ) def is_offline_only(offline_only: bool = False) -> bool: if offline_only: return True return os.environ.get("WESP_OTA_UPDATE", "").strip().lower() in ("1", "true", "yes", "on") def _running_in_virtualenv() -> bool: if os.environ.get("VIRTUAL_ENV"): return True return sys.prefix != sys.base_prefix def _venv_python(venv_root: Path) -> Optional[str]: bindir = venv_root / "bin" if not bindir.is_dir(): return None for name in ("python", "python3"): candidate = bindir / name if candidate.is_file() and os.access(candidate, os.X_OK): return str(candidate.resolve()) for candidate in sorted(bindir.glob("python3.*")): if candidate.is_file() and os.access(candidate, os.X_OK): return str(candidate.resolve()) return None def install_python_executable(root: Optional[Union[str, Path]] = None) -> str: """Python для pip при OTA. Prod (komton): venv ``(wes)`` в ``/opt/wes`` → ``/opt/wes/bin/python3.13``. Приоритет: WESP_PYTHON → WESP_VENV → каталоги рядом с root (OTA) → sys.executable (если уже в venv) → sys.executable. Каталоги рядом с root проверяются до активного venv процесса: иначе pytest/скрипт, запущенный из другого venv, перебивает ../wes_mac у развёрнутого пакета. """ explicit = os.environ.get("WESP_PYTHON", "").strip() if explicit: path = Path(explicit).expanduser() if path.is_file() and os.access(path, os.X_OK): return str(path.resolve()) venv_root = os.environ.get("WESP_VENV", "").strip() if venv_root: resolved = _venv_python(Path(venv_root).expanduser()) if resolved: return resolved base = project_root(root) for venv in ( Path("/opt/wes"), base / ".venv", base.parent / "wes_mac", ): resolved = _venv_python(venv) if resolved: return resolved if _running_in_virtualenv() and sys.executable: return sys.executable return sys.executable def build_filtered_requirements(req_file: Optional[Path] = None) -> str: path = req_file or REQ_FILE if not path.exists(): raise FileNotFoundError(f"requirements.txt не найден: {path}") lines: list[str] = [] for raw in path.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#"): lines.append(raw) continue base_name = line.split("==", 1)[0].split(">=", 1)[0].split("<=", 1)[0].strip().lower() if base_name in RPI_ONLY_PACKAGES: continue lines.append(raw) return "\n".join(lines) + "\n" def _resolve_req_path(root: Path) -> Path: req = root / "requirements-prod.txt" if not req.is_file(): req = root / "requirements.txt" if not req.is_file(): raise FileNotFoundError(f"requirements-prod.txt не найден: {root / 'requirements-prod.txt'}") return req def _pip_install( req_path: Path, wdir: Path, *, offline_only: bool, python_exe: Optional[str] = None, ) -> None: strict = is_offline_only(offline_only) exe = python_exe or sys.executable cmd = [exe, "-m", "pip", "install"] if strict or wheelhouse_has_packages(wdir): if not wheelhouse_has_packages(wdir): raise UpdateDepsError( f"В пакете нет зависимостей (пустой каталог: {wdir})" ) cmd.extend(["--no-index", f"--find-links={wdir}"]) elif not allow_pypi(): raise SystemExit( "Офлайн-установка: в каталоге нет пакетов.\n" f" Ожидался каталог: {wdir}\n" " Выполните: ./scripts/build_release.sh --wheelhouse-only\n" " Либо: WESP_PIP_ALLOW_INDEX=1 (только отладка)" ) if strict: cmd.extend(["--ignore-installed", "--no-warn-script-location"]) cmd.extend(["-r", str(req_path)]) try: subprocess.check_call(cmd) except subprocess.CalledProcessError as exc: if strict: raise UpdateDepsError(f"pip install failed with code {exc.returncode}") from exc raise def install_requirements( root: Optional[Union[str, Path]] = None, *, offline_only: bool = False, ) -> None: """Установить зависимости из requirements.txt; при OTA — только vendor/wheels.""" base = project_root(root) req_path = _resolve_req_path(base) wdir = wheelhouse_dir(base) strict = is_offline_only(offline_only) python_exe = install_python_executable(base) if strict else sys.executable if is_arm(): _pip_install(req_path, wdir, offline_only=offline_only, python_exe=python_exe) return filtered = build_filtered_requirements(req_path) with tempfile.NamedTemporaryFile( "w", encoding="utf-8", delete=False, suffix="-requirements.txt" ) as tmp: tmp.write(filtered) tmp_path = Path(tmp.name) try: _pip_install( tmp_path, wdir, offline_only=offline_only, python_exe=python_exe, ) finally: try: tmp_path.unlink(missing_ok=True) except OSError: pass def production_requirements_path(root: Optional[Union[str, Path]] = None) -> Path: """Prod-зависимости для OTA и wheelhouse (без pytest и dev-only).""" return _resolve_req_path(project_root(root)) def parse_requirement_names(req_text: str, *, filter_rpi: bool = False) -> List[str]: """Имена пакетов из requirements (для verify wheelhouse).""" names: List[str] = [] for raw in req_text.splitlines(): line = raw.strip() if not line or line.startswith("#"): continue if line.startswith(("-r ", "--requirement ", "-c ", "--constraint ")): continue name = re.split(r"[<>=!~]", line, maxsplit=1)[0].strip().lower().replace("_", "-") if not name or name.startswith("-"): continue if filter_rpi and name.replace(".", "") in {p.replace(".", "") for p in RPI_ONLY_PACKAGES}: continue if name in {n.replace("_", "-") for n in names}: continue names.append(name) return names def _wheel_index(wheels_dir: Path) -> set[str]: found: set[str] = set() if not wheels_dir.is_dir(): return found for path in wheels_dir.iterdir(): if not path.is_file(): continue if path.suffix not in (".whl", ".gz", ".zip"): continue name = path.name.lower().replace("_", "-") found.add(name) parts = name.split("-") if parts: found.add(parts[0]) return found def _package_covered(pkg: str, index: set[str]) -> bool: pkg = pkg.lower().replace("_", "-") for entry in index: if entry == pkg or entry.startswith(pkg + "-"): return True return False def missing_wheelhouse_packages(req_file: Path, wheels_dir: Path) -> List[str]: if not wheelhouse_has_packages(wheels_dir): return [""] prod_req = production_requirements_path(req_file.parent) text = prod_req.read_text(encoding="utf-8") if prod_req.is_file() else "" if not is_arm(): text = build_filtered_requirements(prod_req) names = parse_requirement_names(text, filter_rpi=not is_arm()) index = _wheel_index(wheels_dir) return [n for n in names if not _package_covered(n, index)] def main() -> None: install_requirements(ROOT, offline_only=False) if __name__ == "__main__": main()