Skip to content

Commit

Permalink
Add version checking (#171)
Browse files Browse the repository at this point in the history
This is a partial copy of the outdated PR of @HarmvZ who laid the
foundations. However, it now also checks the latest supported version.

The original PR had some problems with double running the check and the
new check needed access to the `base_url` anyway. So I decided put the
checking in the `ClientBase` initialisation but keep it in a separate
httpx Client.

---------

Co-authored-by: HarmvZ <[email protected]>
Co-authored-by: James Meakin <[email protected]>
  • Loading branch information
3 people authored Nov 19, 2024
1 parent b063078 commit ad9f6ff
Show file tree
Hide file tree
Showing 7 changed files with 162 additions and 18 deletions.
43 changes: 43 additions & 0 deletions gcapi/check_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import warnings
from importlib.metadata import version as get_version

import httpx
from packaging import version


class UnsupportedVersionError(Exception):
pass


def check_version(base_url):
package_name = "gcapi"

current_version = get_version(package_name)

with httpx.Client() as client:
response = client.get(f"{base_url}/gcapi/")

api_data = response.json()

latest_version = api_data["latest_version"]
lowest_supported_version = api_data["lowest_supported_version"]

current_version_v = version.parse(current_version)
latest_version_v = version.parse(latest_version)
lowest_supported_version_v = version.parse(lowest_supported_version)

if current_version_v < lowest_supported_version_v:
raise UnsupportedVersionError(
f"You are using {package_name} version {current_version}. "
f"However, the platform only supports {lowest_supported_version} "
"or newer. Upgrade via `pip install --upgrade {package_name}`",
)

if current_version_v < latest_version_v:
warnings.warn(
f"You are using {package_name} version {current_version}. "
f"However, version {latest_version} is available. You should consider"
f" upgrading via `pip install --upgrade {package_name}`",
UserWarning,
stacklevel=0,
)
4 changes: 4 additions & 0 deletions gcapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import gcapi.models
from gcapi.apibase import APIBase, ClientInterface, ModifiableMixin
from gcapi.check_version import check_version
from gcapi.exceptions import ObjectNotFound
from gcapi.retries import BaseRetryStrategy, SelectiveBackoffStrategy
from gcapi.sync_async_hybrid_support import CapturedCall, mark_generator
Expand Down Expand Up @@ -397,6 +398,8 @@ def __init__(
timeout: float = 60.0,
retry_strategy: Optional[Callable[[], BaseRetryStrategy]] = None,
):
check_version(base_url=base_url)

retry_strategy = retry_strategy or SelectiveBackoffStrategy(
backoff_factor=0.1,
maximum_number_of_retries=8, # ~25.5 seconds total backoff
Expand All @@ -410,6 +413,7 @@ def __init__(
retry_strategy=retry_strategy,
),
)

self.headers.update({"Accept": "application/json"})
self._auth_header = _generate_auth_header(token=token)

Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from subprocess import STDOUT, check_output
from tempfile import TemporaryDirectory
from time import sleep
from unittest.mock import patch

import httpx
import pytest
Expand Down Expand Up @@ -184,3 +185,9 @@ def rewrite_docker_compose(content: bytes) -> bytes:
spec["services"]["celery_worker"]["command"] = command

return yaml.safe_dump(spec).encode("utf-8")


@pytest.fixture(autouse=True)
def mock_check_version():
with patch("gcapi.client.check_version") as mock:
yield mock
2 changes: 1 addition & 1 deletion tests/scripts/create_test_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def _create_users(usernames):
for username in usernames:
user = get_user_model().objects.create(
username=username,
email=f"{username}@example.com",
email=f"{username}@example.test",
is_active=True,
first_name=username,
last_name=username,
Expand Down
90 changes: 90 additions & 0 deletions tests/test_check_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import warnings
from contextlib import nullcontext
from unittest.mock import MagicMock, patch

import pytest

from gcapi import AsyncClient, Client
from gcapi.check_version import UnsupportedVersionError, check_version


@pytest.fixture
def mock_get_version():
with patch("gcapi.check_version.get_version") as mock:
yield mock


@pytest.fixture
def mock_httpx_client():
with patch("gcapi.check_version.httpx.Client") as mock:
yield mock


@pytest.mark.parametrize(
"current,latest,lowest,should_warn,expected_context",
[
# Normal operations
("1.0.0", "1.0.0", "0.0.0", False, nullcontext()),
# Newer versions
("1.0.0", "1.0.1", "0.0.0", True, nullcontext()),
("1.0.0", "1.1.0", "0.0.0", True, nullcontext()),
("1.0.0", "2.0.0", "0.0.0", True, nullcontext()),
("1.0.1", "1.0.0", "0.0.0", False, nullcontext()),
("1.1.0", "1.0.0", "0.0.0", False, nullcontext()),
("2.0.0", "1.0.0", "0.0.0", False, nullcontext()),
# Lower supported versions
(
"1.0.0",
"0.0.0",
"2.0.0",
False,
pytest.raises(UnsupportedVersionError),
),
(
"1.0.0",
"3.0.0", # Even if there is a newer version: error out
"2.0.0",
False,
pytest.raises(UnsupportedVersionError),
),
],
)
def test_check_version_comparisons(
mock_get_version,
mock_httpx_client,
current,
latest,
lowest,
should_warn,
expected_context,
):
mock_get_version.return_value = current
mock_response = MagicMock()
mock_response.json.return_value = {
"latest_version": latest,
"lowest_supported_version": lowest,
}
mock_httpx_client.return_value.__enter__.return_value.get.return_value = (
mock_response
)

with warnings.catch_warnings(record=True) as w:
with expected_context:
check_version(base_url="https://example.test/")

if should_warn:
assert (
len(w) == 1
), f"A warning should be issued for version {current} < {latest}"
assert f"You are using gcapi version {current}" in str(w[0].message)
else:
assert (
len(w) == 0
), f"No warning should be issued for version {current} >= {latest}"


@pytest.mark.parametrize("client", [Client, AsyncClient])
def test_check_version_calling(client, mock_check_version):
mock_check_version.assert_not_called() # Sanity
client(token="Foo")
mock_check_version.assert_called_once()
32 changes: 16 additions & 16 deletions tests/test_gcapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,47 +59,47 @@ def test_token_rewriting(monkeypatch, token, environ):

def test_http_base_url():
with pytest.raises(RuntimeError):
Client(token="foo", base_url="http://example.com")
Client(token="foo", base_url="http://example.test")


def test_custom_base_url():
c = Client(token="foo")
assert str(c.base_url).startswith("https://grand-challenge.org")

c = Client(token="foo", base_url="https://example.com")
assert str(c.base_url).startswith("https://example.com")
c = Client(token="foo", base_url="https://example.test")
assert str(c.base_url).startswith("https://example.test")


@pytest.mark.parametrize(
"url",
(
"https://example.com/api/v1/",
"https://example.com/",
"https://example.com",
"https://example.com/another/",
"https://example.com/../../foo/",
"https://example.test/api/v1/",
"https://example.test/",
"https://example.test",
"https://example.test/another/",
"https://example.test/../../foo/",
),
)
def test_same_domain_calls_are_ok(url):
c = Client(token="foo", base_url="https://example.com/api/v1/")
c = Client(token="foo", base_url="https://example.test/api/v1/")
assert c.validate_url(url=url) is None


@pytest.mark.parametrize(
"url",
(
"https://notexample.com/api/v1/",
"http://example.com/api/v1/",
"https://notexample.test/api/v1/",
"http://example.test/api/v1/",
"https://exаmple.com/api/v1/", # а = \u0430
"https://sub.example.com/api/v1/",
"https://sub.example.test/api/v1/",
# This is working now because "URL" normalizes this. Expected!
# "https://example.com:443/api/v1/",
"example.com/api/v1/",
"//example.com/api/v1/",
# "https://example.test:443/api/v1/",
"example.test/api/v1/",
"//example.test/api/v1/",
),
)
def test_invalid_url_fails(url):
c = Client(token="foo", base_url="https://example.com/api/v1/")
c = Client(token="foo", base_url="https://example.test/api/v1/")
with pytest.raises(RuntimeError):
c.validate_url(url=url)

Expand Down
2 changes: 1 addition & 1 deletion tests/test_transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from gcapi.transports import RetryTransport
from tests.utils import mock_transport_responses

MOCK_REQUEST = httpx.Request("GET", "https://example.com")
MOCK_REQUEST = httpx.Request("GET", "https://example.test")
MOCK_RESPONSES = [
httpx.Response(httpx.codes.NOT_FOUND),
httpx.Response(httpx.codes.NOT_FOUND),
Expand Down

0 comments on commit ad9f6ff

Please sign in to comment.