320 lines
10 KiB
Python
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()
|