@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Установка зависимостей из локального каталога колёс (офлайн).
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user