diff --git a/.gitea/workflows/publish-pyfglair.yaml b/.gitea/workflows/publish-pyfglair.yaml index f3deabb..edb555b 100644 --- a/.gitea/workflows/publish-pyfglair.yaml +++ b/.gitea/workflows/publish-pyfglair.yaml @@ -1,21 +1,17 @@ # Сборка и публикация pyfglair в PyPI-реестр пакетов Gitea. -# Запуск: Actions → Publish pyfglair → Run workflow (ручной, workflow_dispatch). +# Запуск: Actions → Publish pyfglair → Run workflow (workflow_dispatch). # -# Авторизация (любой из вариантов): -# 1) встроенный job-токен: permissions: packages: write (Gitea ≥ 1.27 со -# scope packages у features) — секреты не нужны; -# 2) personal access token (scope write:package) в секрете PYPI_TOKEN — -# для старых Gitea, где публикация пакетов job-токеном не реализована -# (см. https://docs.gitea.com/usage/actions/comparison — "Package -# repository authorization"). -# Секрет PYPI_USER (опционально) — имя пользователя к PAT; по умолчанию актор. -# ВНИМАНИЕ: имена секретов не могут начинаться с GITEA_ (зарезервировано). +# Собираются ДВА wheel (одинаковая версия): +# * linux_x86_64 — glibc (HA Supervised/Core на Debian/Ubuntu); +# * musllinux_X_Y — musl (HA OS: Alpine-контейнеры); собирается в +# контейнере python:3.12-alpine. Если runner не поддерживает +# `container:` — job build-musllinux упадёт на apk; в этом случае +# musllinux-wheel соберите вручную (см. docs/RELEASE_HA.md §1.4). # -# Примечания: -# * actions/checkout и actions/setup-python тянутся с github.com -# (если инстанс изолирован — замените на абсолютные URL зеркал); -# * сборка wheel с bundled mbedtls требует доступ к github.com -# (FetchContent); при BUNDLED=0 ставится системный libmbedtls-dev. +# Авторизация: встроенный job-токен при `permissions: packages: write` +# (Gitea ≥ 1.27) либо PAT write:package в секрете PYPI_TOKEN. +# Секрет PYPI_USER — имя пользователя к PAT; имена секретов не могут +# начинаться с GITEA_ (префикс зарезервирован). name: Publish pyfglair on: @@ -31,7 +27,7 @@ permissions: packages: write jobs: - publish: + build-glibc: runs-on: ubuntu-latest steps: - name: Checkout @@ -56,7 +52,7 @@ jobs: cmake --version | head -1 g++ --version | head -1 - - name: Build wheel + sdist + - name: Build wheel + sdist (glibc) env: BUNDLED: ${{ inputs.bundled_mbedtls }} run: | @@ -69,50 +65,71 @@ jobs: python -m build ls -l dist - # Запускать из каталога без исходников: иначе python подхватывает + # Запуск из каталога без исходников: иначе python подхватывает # ./pyfglair (без собранного .so) вместо установленного wheel. - - name: Smoke test wheel + - name: Smoke test wheel (glibc) run: | python -m venv /tmp/wheeltest WHEEL="$(pwd)/dist" /tmp/wheeltest/bin/pip install --quiet "$WHEEL"/*.whl cd /tmp - /tmp/wheeltest/bin/python -P -c "import pyfglair.session as s; print('pyfglair:', s.__file__)" + /tmp/wheeltest/bin/python -P -c "import pyfglair.session as s; print('pyfglair(glibc):', s.__file__)" /tmp/wheeltest/bin/python -P -c "from pyfglair import Template, Prop, convert_to_display; assert convert_to_display(Template.A, Prop.DISPLAY_TEMPERATURE, 7000) == 200" - - name: Publish to Gitea package registry (PyPI) + - name: Publish glibc wheel + sdist env: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + SERVER: ${{ github.server_url }} + OWNER: ${{ github.repository_owner }} + TOKEN: ${{ secrets.PYPI_TOKEN }} + GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }} PYPI_USER: ${{ secrets.PYPI_USER }} - JOB_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: bash scripts/publish-gitea-pypi.sh + + build-musllinux: + runs-on: ubuntu-latest + container: python:3.12-alpine + steps: + - name: Build musllinux wheel + env: + BUNDLED: ${{ inputs.bundled_mbedtls }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git + SHA: ${{ github.sha }} run: | - TOKEN="${PYPI_TOKEN:-$JOB_TOKEN}" - if [ -z "$TOKEN" ]; then - echo "::error::Нет токена: разрешите 'packages: write' (Gitea ≥ 1.27) или задайте секрет PYPI_TOKEN (PAT write:package)" + apk add --no-cache git cmake ninja g++ make curl libstdc++-dev + if [ -z "$REPO_URL" ] || [ "$REPO_URL" = "/.git" ]; then + echo "::error::Пустой github.server_url/repository — задайте контексты или соберите musllinux вручную (docs/RELEASE_HA.md §1.4)" exit 1 fi - SERVER="${GITHUB_SERVER_URL:-}" - if [ -z "$SERVER" ]; then - SERVER="$(git remote get-url origin | sed -E 's#^git@([^:]+):.*#https://\1#; s#^(https?://[^/]+)/.*#\1#')" + git clone --depth 1 "$REPO_URL" /src + cd /src + if [ -n "$SHA" ]; then + git fetch --depth 1 origin "$SHA" + git checkout --detach FETCH_HEAD fi - REPO="${GITHUB_REPOSITORY:-$(git remote get-url origin | sed -E 's#.*[:/]([^/]+/[^/]+?)(\.git)?$#\1#')}" - OWNER="${REPO%%/*}" - GITEA_USER="${PYPI_USER:-${GITHUB_ACTOR:-git}}" - pip install --upgrade twine - - # twine 7 не поддерживает --skip-existing для не-PyPI репозиториев, - # поэтому проверяем simple-индекс Gitea сами. - VERSION="$(basename "$(ls dist/pyfglair-*.tar.gz | head -1)" | sed -E 's/^pyfglair-(.+)\.tar\.gz$/\1/')" - INDEX="$SERVER/api/packages/$OWNER/pypi/simple/pyfglair/" - if curl -fsS -u "$GITEA_USER:$TOKEN" "$INDEX" | grep -q "pyfglair-${VERSION}-"; then - echo "Версия $VERSION уже опубликована — пропуск загрузки." - exit 0 + if [ "$BUNDLED" = "0" ]; then + export FGL_BUNDLED_MBEDTLS=0 + else + export FGL_BUNDLED_MBEDTLS=1 fi + pip install --upgrade build + python -m build --wheel + ls -l dist - python -m twine upload \ - --repository-url "$SERVER/api/packages/$OWNER/pypi" \ - --username "$GITEA_USER" \ - --password "$TOKEN" \ - --non-interactive \ - dist/* - echo "Опубликовано: $SERVER/api/packages/$OWNER/pypi/simple/pyfglair/" + - name: Smoke test wheel (musl) + run: | + cd /src + python -m venv /tmp/wheeltest + /tmp/wheeltest/bin/pip install --quiet "$(pwd)"/dist/*.whl + cd /tmp + /tmp/wheeltest/bin/python -P -c "import pyfglair.session as s; print('pyfglair(musl):', s.__file__)" + /tmp/wheeltest/bin/python -P -c "from pyfglair import Template, Prop, convert_to_display; assert convert_to_display(Template.A, Prop.DISPLAY_TEMPERATURE, 7000) == 200" + + - name: Publish musllinux wheel + env: + SERVER: ${{ github.server_url }} + OWNER: ${{ github.repository_owner }} + TOKEN: ${{ secrets.PYPI_TOKEN }} + GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }} + PYPI_USER: ${{ secrets.PYPI_USER }} + DIST_DIR: /src/dist + run: cd /src && bash scripts/publish-gitea-pypi.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index f1dac7b..516e92f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -125,6 +125,14 @@ else() VERSION ${PROJECT_VERSION} SOVERSION ${PROJECT_VERSION_MAJOR} ) + # Минимум внешних зависимостей у wheel: C++-рантайм встраиваем, остаются + # libc/libm (на musl — musl). Иначе Alpine/HA OS может не иметь + # libstdc++.so.6/libgcc_s.so.1 нужной сборки. + if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + target_link_options(fgl-aircon-shared PRIVATE + -static-libstdc++ -static-libgcc + ) + endif() endif() if(FGL_BUILD_TESTS) diff --git a/custom_components/fglair/README.md b/custom_components/fglair/README.md index 518dfa3..25119e5 100644 --- a/custom_components/fglair/README.md +++ b/custom_components/fglair/README.md @@ -126,6 +126,10 @@ lanip_key_id) или импорт `config_*.json` от `fglair-discover`/`fglctl ## Диагностика и известные ситуации +* **`OSError: cannot load library ... ld-linux-x86-64.so.2: No such file + or directory`** — на HA OS (Alpine/musl) установился glibc-wheel. Нужна + musllinux-сборка `pyfglair`: переустановите пакет из индекса (см. + [`docs/RELEASE_HA.md`](../../docs/RELEASE_HA.md) §1.3) и перезапустите HA. * **«Не удалось загрузить мастер настройки: Invalid handler specified»** — HA не смог импортировать компонент (обычно нет пакета `pyfglair`). Проверьте лог HA: diff --git a/docs/RELEASE_HA.md b/docs/RELEASE_HA.md index 602c3fa..a3c3833 100644 --- a/docs/RELEASE_HA.md +++ b/docs/RELEASE_HA.md @@ -29,9 +29,18 @@ Workflow: `.gitea/workflows/publish-pyfglair.yaml` (workflow объявляет Запуск: Actions → **Publish pyfglair** → Run workflow (вход `bundled_mbedtls`: `1` — встроить mbedtls, `0` — системный -`libmbedtls-dev`). Артефакт появится в +`libmbedtls-dev`). Собираются **два** wheel одной версии: + +* `py3-none-linux_x86_64` — glibc (HA Supervised/Core на Debian/Ubuntu); +* `py3-none-musllinux_1_2_x86_64` — musl (**HA OS**, Alpine-контейнер); + job `build-musllinux` выполняется в `container: python:3.12-alpine` + (нужен `container:` у runner'а; иначе см. §1.4). + +Артефакты появятся в `https://git.ratigorsk-12.ru/api/packages//pypi/simple/pyfglair/` -(`` — владелец репозитория, напр. `esphome`). +(`` — владелец репозитория, напр. `esphome`). C++-рантайм в .so +встроен (`-static-libstdc++ -static-libgcc`), поэтому wheel зависит только +от libc/libm соответствующей платформы. Workflow собирает `wheel` + `sdist`, прогоняет smoke-тест установки (запускается из `/tmp` с `python -P`, чтобы не подхватить исходники @@ -42,6 +51,9 @@ Workflow собирает `wheel` + `sdist`, прогоняет smoke-тест ### 1.2. Установка pyfglair в Home Assistant +Выбор wheel pip делает сам по платформе: на HA OS (Alpine/musl) — из +musllinux, на HA Supervised/Core (Debian/glibc) — из linux-сборки. + Пакет обязателен (cffi-биндинги). Варианты: * **Вручную в окружение HA** (проще всего для теста; HA увидит @@ -66,7 +78,37 @@ Workflow собирает `wheel` + `sdist`, прогоняет smoke-тест * **Публичный PyPI** — см. §2 (для распространения вне сети). -### 1.3. Обновление интеграции +### 1.3. Если HA уже установил glibc-wheel на HA OS + +Симптом: `OSError: cannot load library ... Error loading shared library +ld-linux-x86-64.so.2: No such file or directory`. Переустановите пакет с +musllinux-сборкой: + +```sh +docker exec homeassistant python -m pip uninstall -y pyfglair +docker exec homeassistant python -m pip install --no-deps --index-url \ + https://:@git.ratigorsk-12.ru/api/packages//pypi/simple \ + pyfglair +docker exec homeassistant python -c "import pyfglair; print(pyfglair.__version__)" +docker restart homeassistant +``` + +### 1.4. Ручная сборка musllinux (если runner без `container:`) + +```sh +docker run --rm -v "$PWD":/src -w /src python:3.12-alpine sh -c ' + apk add --no-cache git cmake ninja g++ make curl libstdc++-dev && + pip install build && + python -m build --wheel' +# dist/pyfglair--py3-none-musllinux_1_2_x86_64.whl + +pip install twine +python -m twine upload --repository-url \ + https://git.ratigorsk-12.ru/api/packages//pypi \ + -u -p dist/*musllinux*.whl +``` + +### 1.5. Обновление интеграции `custom_components/fglair/` обновляется из Gitea (git pull / копирование), затем перезапуск HA. `pyfglair` — переустановкой из пакетов Gitea. diff --git a/pyfglair/_corebuild.py b/pyfglair/_corebuild.py index b09f51d..455d558 100644 --- a/pyfglair/_corebuild.py +++ b/pyfglair/_corebuild.py @@ -8,6 +8,8 @@ from __future__ import annotations import argparse import os +import platform +import re import shutil import subprocess import sys @@ -22,6 +24,29 @@ LIB_GLOBS = ( ) +def musl_platform_tag() -> str | None: + """musllinux-тег, если сборка идёт на musl (Alpine/HA OS), иначе None. + + wheel-тег `linux_` на musl вводит pip в заблуждение: он ставит + glibc-сборку на Alpine, и dlopen падает на отсутствующем + ld-linux-.so.2. На musl отдаём корректный `musllinux_X_Y_`. + """ + machine = platform.machine() + try: + result = subprocess.run( + ["ldd", "--version"], capture_output=True, text=True, timeout=10 + ) + except (OSError, subprocess.SubprocessError): + return None + text = (result.stdout or "") + (result.stderr or "") + if "musl" not in text.lower(): + return None + match = re.search(r"(\d+)\.(\d+)", text) + major, minor = (match.group(1), match.group(2)) if match else ("1", "2") + arch = {"amd64": "x86_64", "arm64": "aarch64"}.get(machine, machine) + return f"musllinux_{major}_{minor}_{arch}" + + def source_root() -> Path | None: """Корень монорепо (CMakeLists.txt + include/fgl-aircon).""" env = os.environ.get("FGLAIR_SOURCE_ROOT") diff --git a/scripts/publish-gitea-pypi.sh b/scripts/publish-gitea-pypi.sh new file mode 100644 index 0000000..a273d07 --- /dev/null +++ b/scripts/publish-gitea-pypi.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Публикация файлов из DIST_DIR в PyPI-реестр пакетов Gitea. +# Уже загруженные (по имени файла в simple-индексе) пропускаются: twine 7 не +# поддерживает --skip-existing для не-PyPI репозиториев. +# +# Переменные: +# TOKEN (или GITEA_JOB_TOKEN) — обязательный токен (PAT write:package или +# встроенный job-токен при packages: write) +# DIST_DIR — каталог с *.whl/*.tar.gz (по умолчанию dist) +# SERVER, OWNER — Gitea и владелец; если не заданы, берутся +# из git remote (origin) +# PYPI_USER — имя пользователя (по умолчанию актор) +set -euo pipefail + +DIST_DIR="${DIST_DIR:-dist}" +TOKEN="${TOKEN:-${GITEA_JOB_TOKEN:-}}" + +if [ -z "${SERVER:-}" ]; then + SERVER="$(git remote get-url origin \ + | sed -E 's#^git@([^:]+):.*#https://\1#; s#^(https?://[^/]+)/.*#\1#')" +fi +if [ -z "${OWNER:-}" ]; then + REPO="${GITHUB_REPOSITORY:-$(git remote get-url origin \ + | sed -E 's#.*[:/]([^/]+/[^/]+?)(\.git)?$#\1#')}" + OWNER="${REPO%%/*}" +fi +USER_NAME="${PYPI_USER:-${GITHUB_ACTOR:-git}}" + +if [ -z "$TOKEN" ]; then + echo "нет токена: задайте секрет PYPI_TOKEN (PAT write:package) или" >&2 + echo "разрешите 'packages: write' для встроенного GITEA_TOKEN" >&2 + exit 1 +fi +if [ ! -d "$DIST_DIR" ]; then + echo "нет каталога $DIST_DIR" >&2 + exit 1 +fi + +INDEX="$SERVER/api/packages/$OWNER/pypi/simple/pyfglair/" +pip install --upgrade --quiet twine + +for FILE in "$DIST_DIR"/*; do + NAME="$(basename "$FILE")" + if curl -fsS -u "$USER_NAME:$TOKEN" "$INDEX" 2>/dev/null \ + | grep -qF "$NAME"; then + echo "уже опубликовано: $NAME" + continue + fi + python -m twine upload \ + --repository-url "$SERVER/api/packages/$OWNER/pypi" \ + --username "$USER_NAME" \ + --password "$TOKEN" \ + --non-interactive \ + "$FILE" + echo "опубликовано: $NAME" +done +echo "индекс: $INDEX" diff --git a/setup.py b/setup.py index 29b6964..e06f257 100644 --- a/setup.py +++ b/setup.py @@ -14,15 +14,18 @@ except ImportError: # setuptools < 70 from wheel.bdist_wheel import bdist_wheel as _bdist_wheel ROOT = Path(__file__).resolve().parent +_COREBUILD = None def _load_corebuild(): - spec = spec_from_file_location( - "_pyfglair_corebuild", ROOT / "pyfglair" / "_corebuild.py" - ) - module = module_from_spec(spec) - spec.loader.exec_module(module) - return module + global _COREBUILD + if _COREBUILD is None: + spec = spec_from_file_location( + "_pyfglair_corebuild", ROOT / "pyfglair" / "_corebuild.py" + ) + _COREBUILD = module_from_spec(spec) + spec.loader.exec_module(_COREBUILD) + return _COREBUILD class build_py(_build_py): @@ -46,10 +49,17 @@ class BinaryDistribution(Distribution): class bdist_wheel(_bdist_wheel): - """Тег py3-none-: .so собран в ABI-режиме cffi (не CPython-ABI).""" + """Тег py3-none-: .so собран в ABI-режиме cffi (не CPython-ABI). + + На musl (Alpine/HA OS) платформа заменяется на musllinux_X_Y_, + иначе pip поставит glibc-сборку и dlopen упадёт. + """ def get_tag(self): _, _, platform = super().get_tag() + musl = _load_corebuild().musl_platform_tag() + if musl is not None and platform.startswith("linux_"): + platform = musl return "py3", "none", platform diff --git a/tests/pyfglair/test_corebuild.py b/tests/pyfglair/test_corebuild.py new file mode 100644 index 0000000..ccc8363 --- /dev/null +++ b/tests/pyfglair/test_corebuild.py @@ -0,0 +1,49 @@ +"""Определение платформы wheel: musl (Alpine/HA OS) vs glibc.""" +from __future__ import annotations + +from pyfglair import _corebuild + + +class _Result: + def __init__(self, stdout: str = "", stderr: str = "") -> None: + self.stdout = stdout + self.stderr = stderr + + +def test_glibc_returns_none(monkeypatch): + monkeypatch.setattr( + _corebuild.subprocess, + "run", + lambda *args, **kwargs: _Result("ldd (GNU libc) 2.42\n"), + ) + assert _corebuild.musl_platform_tag() is None + + +def test_musl_x86_64(monkeypatch): + monkeypatch.setattr( + _corebuild.subprocess, + "run", + lambda *args, **kwargs: _Result("musl libc (x86_64)\nVersion 1.2.5\n"), + ) + monkeypatch.setattr(_corebuild.platform, "machine", lambda: "x86_64") + assert _corebuild.musl_platform_tag() == "musllinux_1_2_x86_64" + + +def test_musl_aarch64(monkeypatch): + monkeypatch.setattr( + _corebuild.subprocess, + "run", + lambda *args, **kwargs: _Result( + "musl libc (aarch64)\nVersion 1.2.4\n" + ), + ) + monkeypatch.setattr(_corebuild.platform, "machine", lambda: "aarch64") + assert _corebuild.musl_platform_tag() == "musllinux_1_2_aarch64" + + +def test_ldd_missing(monkeypatch): + def boom(*args, **kwargs): + raise FileNotFoundError("ldd") + + monkeypatch.setattr(_corebuild.subprocess, "run", boom) + assert _corebuild.musl_platform_tag() is None