diff --git a/.github/workflows/polyapi-update-python-package.yml b/.github/workflows/polyapi-update-python-package.yml index 1612bb8..efb838c 100644 --- a/.github/workflows/polyapi-update-python-package.yml +++ b/.github/workflows/polyapi-update-python-package.yml @@ -1,4 +1,5 @@ name: Update python pip package + on: push: paths: @@ -6,106 +7,217 @@ on: branches: - develop - main + pull_request: + types: [closed] + paths: + - "pyproject.toml" + branches: + - develop + - main + - eu1 + - na2 jobs: - develop-build: + build-distribution: name: Build distribution 📦 runs-on: ubuntu-latest - if: ${{ github.ref == 'refs/heads/develop' }} - environment: dev steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.x" - - - - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: python3 -m build - - name: Store the distribution packages - uses: actions/upload-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - - dev-publish-to-pypi: - name: Publish Python 🐍 distribution 📦 to PyPI + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install build tooling + run: python -m pip install --upgrade pip build tomli twine + + - name: Build a binary wheel and a source tarball + run: python -m build + + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Resolve package version + id: pkg + shell: bash + run: | + VERSION=$(python -c "import pathlib, tomli; data = tomli.loads(pathlib.Path('pyproject.toml').read_text(encoding='utf-8')); print(data['project']['version'])") + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Resolved package version: ${VERSION}" + + outputs: + package_version: ${{ steps.pkg.outputs.version }} + + publish-develop: + name: Publish to PyPI and update develop client-versions runs-on: ubuntu-latest + needs: build-distribution + environment: develop + permissions: + id-token: write if: ${{ github.ref == 'refs/heads/develop' }} - needs: develop-build - environment: - name: dev - url: https://pypi.org/p/polyapi-python + steps: + - name: Download all dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Update backend client-versions + env: + BASE_URL: https://dev.polyapi.io + VERSION: ${{ needs.build-distribution.outputs.package_version }} + POLY_SUPER_ADMIN_USER_KEY: ${{ secrets.POLY_SUPER_ADMIN_USER_KEY }} + shell: bash + run: | + if [[ -z "${POLY_SUPER_ADMIN_USER_KEY}" ]]; then + echo "Missing required secret POLY_SUPER_ADMIN_USER_KEY in environment 'develop'" + exit 1 + fi + + curl --fail-with-body -X PATCH \ + "${BASE_URL}/config-variables/SupportedClientVersions" \ + -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"SupportedClientVersions\",\"value\":{\"python\":\"${VERSION}\"}}" + + publish-na1: + name: Publish to PyPI and update na1 BE client-versions + runs-on: ubuntu-latest + needs: build-distribution + environment: na1 permissions: id-token: write - steps: - - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + if: >- + ${{ + github.ref == 'refs/heads/main' || + ( + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.head.ref == 'develop' && + github.event.pull_request.base.ref == 'main' + ) + }} - - main-build: - name: Build distribution 📦 + steps: + - name: Download all dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Update backend client-versions + env: + BASE_URL: https://na1.polyapi.io + VERSION: ${{ needs.build-distribution.outputs.package_version }} + POLY_SUPER_ADMIN_USER_KEY: ${{ secrets.POLY_SUPER_ADMIN_USER_KEY }} + shell: bash + run: | + if [[ -z "${POLY_SUPER_ADMIN_USER_KEY}" ]]; then + echo "Missing required secret POLY_SUPER_ADMIN_USER_KEY in environment 'na1'" + exit 1 + fi + + curl --fail-with-body -X PATCH \ + "${BASE_URL}/config-variables/SupportedClientVersions" \ + -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"SupportedClientVersions\",\"value\":{\"python\":\"${VERSION}\"}}" + + publish-na2: + name: Publish to PyPI and update na2 client-versions runs-on: ubuntu-latest - if: ${{ github.ref == 'refs/heads/main' }} - environment: main + needs: build-distribution + environment: na2 + permissions: + id-token: write + if: >- + ${{ + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.head.ref == 'main' && + github.event.pull_request.base.ref == 'na2' + }} steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.x" - - - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: python3 -m build - - name: Store the distribution packages - uses: actions/upload-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - - main-publish-to-pypi: - name: >- - Publish Python 🐍 distribution 📦 to PyPI - if: ${{ github.ref == 'refs/heads/main' }} - needs: - - main-build + - name: Download all dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Update backend client-versions + env: + BASE_URL: https://na2.polyapi.io + VERSION: ${{ needs.build-distribution.outputs.package_version }} + POLY_SUPER_ADMIN_USER_KEY: ${{ secrets.POLY_SUPER_ADMIN_USER_KEY }} + shell: bash + run: | + if [[ -z "${POLY_SUPER_ADMIN_USER_KEY}" ]]; then + echo "Missing required secret POLY_SUPER_ADMIN_USER_KEY in environment 'na2'" + exit 1 + fi + + curl --fail-with-body -X PATCH \ + "${BASE_URL}/config-variables/SupportedClientVersions" \ + -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"SupportedClientVersions\",\"value\":{\"python\":\"${VERSION}\"}}" + + publish-eu1: + name: Publish to PyPI and update eu1 BE client-versions runs-on: ubuntu-latest - environment: - name: main - url: https://pypi.org/p/polyapi-python + needs: build-distribution + environment: eu1 permissions: id-token: write - + if: >- + ${{ + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.head.ref == 'main' && + github.event.pull_request.base.ref == 'eu1' + }} steps: - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Download all dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Update backend client-versions + env: + BASE_URL: https://eu1.polyapi.io + VERSION: ${{ needs.build-distribution.outputs.package_version }} + POLY_SUPER_ADMIN_USER_KEY: ${{ secrets.POLY_SUPER_ADMIN_USER_KEY }} + shell: bash + run: | + if [[ -z "${POLY_SUPER_ADMIN_USER_KEY}" ]]; then + echo "Missing required secret POLY_SUPER_ADMIN_USER_KEY in environment 'eu1'" + exit 1 + fi + + curl --fail-with-body -X PATCH \ + "${BASE_URL}/config-variables/SupportedClientVersions" \ + -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"SupportedClientVersions\",\"value\":{\"python\":\"${VERSION}\"}}" diff --git a/polyapi/cli.py b/polyapi/cli.py index 2187276..1d96bb0 100644 --- a/polyapi/cli.py +++ b/polyapi/cli.py @@ -10,6 +10,7 @@ from .rendered_spec import get_and_update_rendered_spec from .prepare import prepare_deployables from .sync import sync_deployables +from .version_check import check_for_client_version_update def _get_version_string(): @@ -23,6 +24,8 @@ def _get_version_string(): def execute_from_cli(): + check_for_client_version_update() + # First we setup all our argument parsing logic # Then we parse the arguments (waaay at the bottom) parser = argparse.ArgumentParser( diff --git a/polyapi/version_check.py b/polyapi/version_check.py new file mode 100644 index 0000000..4773f93 --- /dev/null +++ b/polyapi/version_check.py @@ -0,0 +1,201 @@ +import importlib.metadata +import logging +import os +import re +import subprocess +import sys +from urllib.parse import urlparse + +from packaging.version import InvalidVersion, Version + +from .config import get_api_key_and_url +from .http_client import get as http_get + +logger = logging.getLogger("poly") + +_REEXEC_GUARD_ENV = "POLY_VERSION_REEXEC_GUARD" + + +def _get_client_version() -> str | None: + try: + return importlib.metadata.version("polyapi-python") + except Exception: + return None + + +def _normalize_version(version: str | None) -> Version | None: + if not version: + return None + + candidate = version.strip() + if not candidate: + return None + + try: + return Version(candidate) + except InvalidVersion: + pass + + match = re.search(r"(\d+(?:\.\d+){0,2}(?:[a-zA-Z]+\d*)?)", candidate) + if not match: + return None + + try: + return Version(match.group(1)) + except InvalidVersion: + return None + + +def get_instance_tag_from_base_url(base_url: str | None) -> str | None: + if not base_url: + return None + + try: + host = urlparse(base_url).hostname + if not host: + return None + host_prefix = host.lower().split(".")[0] + + if host_prefix in ("dev", "develop"): + return "develop" + if host_prefix == "staging": + return "staging" + if host_prefix == "test": + return "test" + if host_prefix == "na2": + return "na2" + if host_prefix == "eu1": + return "eu1" + return "na1" + except Exception: + return None + + +def _resolve_instance_tag(base_url: str | None) -> str | None: + explicit = os.getenv("POLY_INSTANCE_TAG") + if explicit: + return explicit.strip() + return get_instance_tag_from_base_url(base_url) + + +def _get_client_versions(base_url: str) -> dict: + url = f"{base_url.rstrip('/')}/config-variables/SupportedClientVersions" + api_key, _ = get_api_key_and_url() + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + resp = http_get(url, headers=headers, timeout=5.0) + resp.raise_for_status() + payload = resp.json() + if not isinstance(payload, dict): + raise ValueError("Invalid SupportedClientVersions config variablepayload.") + + value = payload.get("value") + if isinstance(value, dict): + return value + + if isinstance(payload.get("python"), str): + return payload + + raise ValueError("Invalid SupportedClientVersions config variable payload.") + +def _resolve_target_version(versions_payload: dict) -> str | None: + if isinstance(versions_payload, dict): + value = versions_payload.get("python") + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _run_update(target_version: str) -> bool: + cmd = [ + sys.executable, + "-m", + "pip", + "install", + "--upgrade", + f"polyapi-python=={target_version}", + ] + try: + subprocess.run(cmd, check=True) + return True + except Exception as ex: + logger.error("Failed to update polyapi-python to %s: %s", target_version, ex) + return False + + +def _reexec_process(base_url: str | None = None, api_key: str | None = None) -> None: + os.environ[_REEXEC_GUARD_ENV] = "1" + + # preserve already-resolved config values so initialize_config won't prompt again + if base_url: + os.environ["POLY_API_BASE_URL"] = base_url + if api_key: + os.environ["POLY_API_KEY"] = api_key + + argv = [sys.executable, "-m", "polyapi", *sys.argv[1:]] + os.execv(sys.executable, argv) + + +def check_for_client_version_update() -> None: + # prevent update/re-exec loops + if os.getenv(_REEXEC_GUARD_ENV) == "1": + return + + # Reuse existing config flow for base URL retrieval + api_key, base_url = get_api_key_and_url() + base_url = base_url or os.getenv("POLY_API_BASE_URL") + if not base_url: + return + + instance_tag = _resolve_instance_tag(base_url) + if not instance_tag: + return + + current_version = _get_client_version() + normalized_current = _normalize_version(current_version) + if not current_version or not normalized_current: + return + + try: + versions_payload = _get_client_versions(base_url) + except Exception as ex: + logger.error("Failed to fetch client versions from the service: %s", ex) + return + + available_version = _resolve_target_version(versions_payload) + normalized_available = _normalize_version(available_version) + if not available_version or not normalized_available: + return + + using_older_version = normalized_current < normalized_available + using_newer_version = normalized_current > normalized_available + + if not using_older_version and not using_newer_version: + return + + warning_message = ( + f'Instance "{instance_tag}" uses ' + f'{"a later" if using_older_version else "an older"} version of the Poly client. ' + f"Current: {current_version}, Instance: {available_version}." + ) + + non_interactive_mode = bool( + os.getenv("POLY_API_KEY") or os.getenv("POLY_API_BASE_URL") + ) + + if non_interactive_mode: + print(f"{warning_message} Please update to avoid any issues.") + return + + should_update_input = input(f"{warning_message} Update now? [Y/n]: ").strip().lower() + should_update = should_update_input in ("", "y", "yes") + + if should_update: + updated = _run_update(available_version) + if updated: + _reexec_process(base_url=base_url, api_key=api_key) + return + + print( + f'Continuing with {"older" if using_older_version else "newer"} ' + f"Poly client version {current_version}. Please update to avoid any issues." + ) \ No newline at end of file diff --git a/tests/test_version_check.py b/tests/test_version_check.py new file mode 100644 index 0000000..b993a4e --- /dev/null +++ b/tests/test_version_check.py @@ -0,0 +1,128 @@ +import os + +from polyapi import version_check + + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + +def test_get_instance_tag_from_base_url(): + assert version_check.get_instance_tag_from_base_url("https://dev.polyapi.io") == "develop" + assert version_check.get_instance_tag_from_base_url("https://develop.polyapi.io") == "develop" + assert version_check.get_instance_tag_from_base_url("https://staging.polyapi.io") == "staging" + assert version_check.get_instance_tag_from_base_url("https://test.polyapi.io") == "test" + assert version_check.get_instance_tag_from_base_url("https://na2.polyapi.io") == "na2" + assert version_check.get_instance_tag_from_base_url("https://eu1.polyapi.io") == "eu1" + assert version_check.get_instance_tag_from_base_url("https://na1.polyapi.io") == "na1" + assert version_check.get_instance_tag_from_base_url("not-a-url") is None + + +def test_check_version_non_interactive_warns(monkeypatch, capsys): + monkeypatch.delenv("POLY_VERSION_REEXEC_GUARD", raising=False) + monkeypatch.setenv("POLY_API_KEY", "k") + monkeypatch.setenv("POLY_API_BASE_URL", "https://eu1.polyapi.io") + + monkeypatch.setattr(version_check, "get_api_key_and_url", lambda: ("k", "https://eu1.polyapi.io")) + monkeypatch.setattr(version_check, "_get_client_version", lambda: "1.0.0") + monkeypatch.setattr( + version_check, + "http_get", + lambda *args, **kwargs: _FakeResponse({"python": "1.2.0", "typescript": "1.20"}), + ) + + version_check.check_for_client_version_update() + out = capsys.readouterr().out + assert 'Instance "eu1" uses a later version of the Poly client. Current: 1.0.0, Instance: 1.2.0.' in out + assert "Please update to avoid any issues." in out + + +def test_check_version_interactive_decline(monkeypatch, capsys): + monkeypatch.delenv("POLY_VERSION_REEXEC_GUARD", raising=False) + monkeypatch.delenv("POLY_API_KEY", raising=False) + monkeypatch.delenv("POLY_API_BASE_URL", raising=False) + + monkeypatch.setattr(version_check, "get_api_key_and_url", lambda: ("k", "https://eu1.polyapi.io")) + monkeypatch.setattr(version_check, "_get_client_version", lambda: "1.0.0") + monkeypatch.setattr( + version_check, + "http_get", + lambda *args, **kwargs: _FakeResponse({"python": "1.2.0", "typescript": "1.20.0"}), + ) + monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "n") + + version_check.check_for_client_version_update() + out = capsys.readouterr().out + assert "Continuing with older Poly client version 1.0.0. Please update to avoid any issues." in out + + +def test_check_version_interactive_accept_updates_and_reexec(monkeypatch): + monkeypatch.delenv("POLY_VERSION_REEXEC_GUARD", raising=False) + monkeypatch.delenv("POLY_API_KEY", raising=False) + monkeypatch.delenv("POLY_API_BASE_URL", raising=False) + + monkeypatch.setattr(version_check, "get_api_key_and_url", lambda: ("k", "https://eu1.polyapi.io")) + monkeypatch.setattr(version_check, "_get_client_version", lambda: "1.0.0") + monkeypatch.setattr( + version_check, + "http_get", + lambda *args, **kwargs: _FakeResponse({"python": "1.2.0", "typescript": "1.20.0"}), + ) + monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "y") + + state = {"updated": False, "reexec": False} + + def _fake_run_update(_v): + state["updated"] = True + return True + + def _fake_reexec(*args, **kwargs): + state["reexec"] = True + + monkeypatch.setattr(version_check, "_run_update", _fake_run_update) + monkeypatch.setattr(version_check, "_reexec_process", _fake_reexec) + + version_check.check_for_client_version_update() + + assert state["updated"] is True + assert state["reexec"] is True + + +def test_check_skips_when_python_version_missing(monkeypatch, capsys): + monkeypatch.delenv("POLY_VERSION_REEXEC_GUARD", raising=False) + monkeypatch.setenv("POLY_API_KEY", "k") + monkeypatch.setenv("POLY_API_BASE_URL", "https://na2.polyapi.io") + + monkeypatch.setattr(version_check, "get_api_key_and_url", lambda: ("k", "https://na2.polyapi.io")) + monkeypatch.setattr(version_check, "_get_client_version", lambda: "1.0.0") + monkeypatch.setattr( + version_check, + "http_get", + lambda *args, **kwargs: _FakeResponse({"typescript": "1.20"}), + ) + + version_check.check_for_client_version_update() + out = capsys.readouterr().out + assert out == "" + + +def test_reexec_guard_prevents_flow(monkeypatch): + monkeypatch.setenv("POLY_VERSION_REEXEC_GUARD", "1") + called = {"get_api": False} + + def _fake_get_api_key_and_url(): + called["get_api"] = True + return ("k", "https://eu1.polyapi.io") + + monkeypatch.setattr(version_check, "get_api_key_and_url", _fake_get_api_key_and_url) + version_check.check_for_client_version_update() + assert called["get_api"] is False + + monkeypatch.delenv("POLY_VERSION_REEXEC_GUARD", raising=False) \ No newline at end of file