Files
site/WESP_REL/install_deps.py
T
2026-07-17 12:57:18 +03:00

320 lines
10 KiB
Python

"""
Установка зависимостей из локального каталога колёс (офлайн).
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 ["<wheelhouse empty>"]
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()