Skip to content

Commit

Permalink
Incorporates feedback
Browse files Browse the repository at this point in the history
- Channel/Architecture data is no longer being provided by a hard-coded mapping
  and is instead being pulled from a remote source
- Fixes type in the Makefile
- Adds unit testing
  • Loading branch information
schuylermartin45 committed Jan 11, 2024
1 parent c745ccb commit c46d045
Show file tree
Hide file tree
Showing 7 changed files with 96,199 additions and 2,747 deletions.
118 changes: 31 additions & 87 deletions anaconda_packaging_utils/api/repodata_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
log = logging.getLogger(__name__)


class Channel(str, Enum):
class Channel(Enum):
"""
Enumeration of channels supported on `repo.anaconda.com`
"""
Expand All @@ -40,7 +40,7 @@ class Channel(str, Enum):
MSYS_2 = "msys2"


class Architecture(str, Enum):
class Architecture(Enum):
"""
Enumeration of architectures supported on `repo.anaconda.com`
"""
Expand All @@ -62,86 +62,6 @@ class Architecture(str, Enum):

_BASE_REPODATA_URL: Final[str] = "https://repo.anaconda.com/pkgs"

# Maps the available architectures per channel hosted on `repo.anaconda.com`
_SUPPORTED_CHANNEL_ARCH: Final[dict[Channel, set[Architecture]]] = {
Channel.MAIN: {
Architecture.LINUX_X86_64,
Architecture.LINUX_X86_32,
Architecture.LINUX_GRAVITON_2,
Architecture.LINUX_S390,
Architecture.LINUX_PPC64LE,
Architecture.OSX_X86_64,
Architecture.OSX_ARM64,
Architecture.WIN_64,
Architecture.WIN_32,
Architecture.NO_ARCH,
},
Channel.FREE: {
Architecture.LINUX_X86_64,
Architecture.LINUX_X86_32,
Architecture.LINUX_ARM_V6L,
Architecture.LINUX_ARM_V7L,
Architecture.LINUX_PPC64LE,
Architecture.OSX_X86_64,
Architecture.OSX_X86_32,
Architecture.WIN_64,
Architecture.WIN_32,
Architecture.NO_ARCH,
},
Channel.R: {
Architecture.LINUX_X86_64,
Architecture.LINUX_X86_32,
Architecture.LINUX_ARM_V6L,
Architecture.LINUX_ARM_V7L,
Architecture.LINUX_PPC64LE,
Architecture.OSX_X86_64,
Architecture.OSX_X86_32,
Architecture.WIN_64,
Architecture.WIN_32,
Architecture.NO_ARCH,
},
Channel.PRO: {
Architecture.LINUX_X86_64,
Architecture.LINUX_X86_32,
Architecture.LINUX_ARM_V6L,
Architecture.LINUX_ARM_V7L,
Architecture.LINUX_PPC64LE,
Architecture.OSX_X86_64,
Architecture.OSX_X86_32,
Architecture.WIN_64,
Architecture.WIN_32,
Architecture.NO_ARCH,
},
Channel.ARCHIVE: {
Architecture.LINUX_X86_64,
Architecture.LINUX_X86_32,
Architecture.LINUX_ARM_V6L,
Architecture.LINUX_ARM_V7L,
Architecture.LINUX_PPC64LE,
Architecture.OSX_X86_64,
Architecture.OSX_X86_32,
Architecture.WIN_64,
Architecture.WIN_32,
Architecture.NO_ARCH,
},
Channel.MRO_ARCHIVE: {
Architecture.LINUX_X86_64,
Architecture.LINUX_X86_32,
Architecture.LINUX_ARM_V6L,
Architecture.LINUX_ARM_V7L,
Architecture.LINUX_PPC64LE,
Architecture.OSX_X86_64,
Architecture.OSX_X86_32,
Architecture.WIN_64,
Architecture.WIN_32,
Architecture.NO_ARCH,
},
Channel.MSYS_2: {
Architecture.WIN_64,
Architecture.WIN_32,
},
}


class ApiException(BaseApiException):
"""
Expand All @@ -151,6 +71,18 @@ class ApiException(BaseApiException):
pass


# Schema for the `channeldata.json` structure. As of writing, this only validates two fields of the data structure, as
# we only use this JSON file to determine which architectures are supported per channel.
_CHANNELDATA_JSON_SCHEMA: Final[SchemaType] = {
"type": "object",
"required": ["channeldata_version", "subdirs"],
"properties": {
"channeldata_version": {"type": "integer"},
"subdirs": {"type": "array", "items": {"type": "string"}},
},
}


@dataclass
class RepodataMetadata:
"""
Expand Down Expand Up @@ -281,17 +213,29 @@ class Repodata:

def _calc_request_url(channel: Channel, arch: Architecture) -> str:
"""
Calculates the URL to the target `repodata.json` blob.
Calculates the URL to the target `repodata.json` blob AND verifies if the requested architecture is supported on
the requested channel.
:param channel: Target publishing channel.
:param arch: Target package architecture. Some older reference material calls this "subdir"
:raises ApiException: If the target channel and architecture are not supported
:returns: URL to the repodata JSON blob of interest.
"""
if channel not in _SUPPORTED_CHANNEL_ARCH:
if channel not in set(Channel):
raise ApiException(f"Requested package channel is not supported: {channel}")
if arch not in _SUPPORTED_CHANNEL_ARCH[channel]:
raise ApiException(f"Requested architecture `{arch}` is not supported by this channel: {channel}")
return f"{_BASE_REPODATA_URL}/{channel}/{arch}/repodata.json"

response_json: JsonType
try:
response_json = make_request_and_validate(
f"{_BASE_REPODATA_URL}/{channel.value}/channeldata.json",
_CHANNELDATA_JSON_SCHEMA,
log,
)
except BaseApiException as e:
raise ApiException(e.message) from e

if arch.value not in cast(list[str], cast(JsonObjectType, response_json)["subdirs"]):
raise ApiException(f"Requested architecture `{arch.value}` is not supported by this channel: {channel.value}")
return f"{_BASE_REPODATA_URL}/{channel.value}/{arch.value}/repodata.json"


def _serialize_repodata_metadata(obj: JsonObjectType) -> RepodataMetadata:
Expand Down
79 changes: 63 additions & 16 deletions anaconda_packaging_utils/tests/api/test_repodata_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,59 @@
File: test_repodata_api.py
Description: Tests the repodata API
"""
from typing import Final, no_type_check
from typing import Final, cast, no_type_check
from unittest.mock import patch

import pytest

from anaconda_packaging_utils.api import repodata_api
from anaconda_packaging_utils.api._utils import make_request_and_validate
from anaconda_packaging_utils.tests.testing_utils import MOCK_BASE_URL, TEST_FILES_PATH, load_json_file
from anaconda_packaging_utils.tests.testing_utils import (
MOCK_BASE_URL,
TEST_FILES_PATH,
MockHttpJsonResponse,
load_json_file,
)

TEST_REPODATA_FILES: Final[str] = f"{TEST_FILES_PATH}/repodata_api"


def mock_requests_get(*args: tuple[str], **_: dict[str, str | int]) -> MockHttpJsonResponse:
"""
Mocking function for HTTP requests made in this test file
:param args: Arguments passed to the `requests.get()`
:param _: Name-specified arguments passed to `requests.get()` (Unused)
"""
known_endpoints: Final[dict[str, MockHttpJsonResponse]] = {
## channeldata.json ##
"https://repo.anaconda.com/pkgs/main/channeldata.json": MockHttpJsonResponse(
200, json_file=f"{TEST_REPODATA_FILES}/channeldata_main.json"
),
"https://repo.anaconda.com/pkgs/msys2/channeldata.json": MockHttpJsonResponse(
200, json_file=f"{TEST_REPODATA_FILES}/channeldata_msys2.json"
),
# This channel is purposefully broken to test failure scenarios
"https://repo.anaconda.com/pkgs/archive/channeldata.json": MockHttpJsonResponse(500, json_data={}),
## repodata.json ##
"https://repo.anaconda.com/pkgs/main/linux-64/repodata.json": MockHttpJsonResponse(
200, json_file=f"{TEST_REPODATA_FILES}/repodata_main_linux64_small.json"
),
"https://repo.anaconda.com/pkgs/main/osx-64/repodata.json": MockHttpJsonResponse(
200, json_file=f"{TEST_REPODATA_FILES}/repodata_main_osx64.json"
),
"https://repo.anaconda.com/pkgs/r/linux-ppc64le/repodata.json": MockHttpJsonResponse(
200, json_file=f"{TEST_REPODATA_FILES}/repodata_r_ppc64le.json"
),
# This combination is purposefully broken to test failure scenarios
"https://repo.anaconda.com/pkgs/msys2/linux-32/repodata.json": MockHttpJsonResponse(500, json_data={}),
}

endpoint = cast(str, args[0])
if endpoint in known_endpoints:
return known_endpoints[endpoint]
return MockHttpJsonResponse(404)


@no_type_check
@pytest.fixture(name="expected_bool")
def fixture_expected_bool(request: pytest.FixtureRequest) -> bool:
Expand Down Expand Up @@ -201,6 +242,8 @@ def test_validate_repodata_schema(file: str) -> None:
Validates the jsonschema representation of a `repodata.json` against several examples.
This is intended to validate the schema format, not necessarily to test `make_request_and_validate()`
"""
# We deliberately do not use `mock_request_get()` in this test to ensure we can parse the small AND original
# `main_linux64` files.
response_json = load_json_file(f"{TEST_REPODATA_FILES}/{file}")
with patch("requests.get") as mock_get:
mock_get.return_value.status_code = 200
Expand All @@ -217,11 +260,7 @@ def test_fetch_repodata_success() -> None:
Tests the serialization of an entire `repodata.json` blob. We use a small fake `repodata.json` file for ease of
validating against.
"""
response_json = load_json_file(f"{TEST_REPODATA_FILES}/repodata_main_linux64_smaller.json")
with patch("requests.get") as mock_get:
mock_get.return_value.status_code = 200
mock_get.return_value.headers = {"content-type": "application/json"}
mock_get.return_value.json.return_value = response_json
with patch("requests.get", new=mock_requests_get):
assert repodata_api.fetch_repodata(
repodata_api.Channel.MAIN, repodata_api.Architecture.LINUX_X86_64
) == repodata_api.Repodata(
Expand Down Expand Up @@ -311,20 +350,28 @@ def test_fetch_repodata_success() -> None:


@no_type_check
def test_fetch_repodata_failure() -> None:
def test_fetch_repodata_bad_input() -> None:
"""
Tests failure scenarios of fetching a repo
Tests failure scenarios caused by bad user input
"""
with patch("requests.get") as mock_get:
mock_get.return_value.status_code = 200
mock_get.return_value.headers = {"content-type": "application/json"}
mock_get.return_value.json.return_value = {}
# Test bad JSON failure
with pytest.raises(repodata_api.ApiException):
repodata_api.fetch_repodata(repodata_api.Channel.MAIN, repodata_api.Architecture.LINUX_X86_64)
with patch("requests.get", new=mock_requests_get):
# Test bad channel request
with pytest.raises(repodata_api.ApiException):
repodata_api.fetch_repodata("fake channel", repodata_api.Architecture.OSX_ARM64)
# Test bad architecture on channel request
with pytest.raises(repodata_api.ApiException):
repodata_api.fetch_repodata(repodata_api.Channel.MSYS_2, repodata_api.Architecture.OSX_ARM64)


@no_type_check
def test_fetch_repodata_bad_json_response() -> None:
"""
Tests failure scenario where the API replies with a bad JSON payload
"""
with patch("requests.get", new=mock_requests_get):
# The archive channel is purposefully broken to simulate a `channeldata.json` HTTP server error
with pytest.raises(repodata_api.ApiException):
repodata_api.fetch_repodata(repodata_api.Channel.ARCHIVE, repodata_api.Architecture.LINUX_X86_64)
# msys2 + Linux x86 32-bit endpoint is a purposefully broken combination that simulates an HTTP server error
with pytest.raises(repodata_api.ApiException):
repodata_api.fetch_repodata(repodata_api.Channel.MSYS_2, repodata_api.Architecture.LINUX_X86_32)
Loading

0 comments on commit c46d045

Please sign in to comment.