From ba6cec380e8219136813cb9642d9486c3f31c522 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 5 Dec 2023 19:11:02 -0500 Subject: [PATCH 1/2] feat: charm list_resource_revisions --- craft_store/base_client.py | 16 ++++ craft_store/models/resource_revision_model.py | 73 +++++++++++++++++++ .../test_list_resource_revisions.py | 61 ++++++++++++++++ tests/unit/test_base_client.py | 69 ++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 craft_store/models/resource_revision_model.py create mode 100644 tests/integration/test_list_resource_revisions.py diff --git a/craft_store/base_client.py b/craft_store/base_client.py index d386e5f..db81454 100644 --- a/craft_store/base_client.py +++ b/craft_store/base_client.py @@ -31,6 +31,7 @@ from . import endpoints, errors, models from .auth import Auth from .http_client import HTTPClient +from .models.resource_revision_model import CharmResourceRevision from .models.revisions_model import RevisionModel logger = logging.getLogger(__name__) @@ -298,6 +299,21 @@ def list_revisions(self, name: str) -> List[RevisionModel]: return [RevisionModel.unmarshal(r) for r in response["revisions"]] + def list_resource_revisions( + self, name: str, resource_name: str + ) -> List[CharmResourceRevision]: + """List the revisions for a specific resource of a specific name.""" + namespace = self._endpoints.namespace + if namespace != "charm": + raise NotImplementedError( + f"Cannot get resource revisions in namespace {namespace}." + ) + endpoint = f"/v1/{namespace}/{name}/resources/{resource_name}/revisions" + response = self.request("GET", self._base_url + endpoint) + model = response.json() + + return [CharmResourceRevision.unmarshal(r) for r in model["revisions"]] + def get_list_releases(self, *, name: str) -> models.MarshableModel: """Query the list_releases endpoint and return the result.""" endpoint = f"/v1/{self._endpoints.namespace}/{name}/releases" diff --git a/craft_store/models/resource_revision_model.py b/craft_store/models/resource_revision_model.py new file mode 100644 index 0000000..d0712af --- /dev/null +++ b/craft_store/models/resource_revision_model.py @@ -0,0 +1,73 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +"""Resource revision response models for the Store.""" +import datetime +from enum import Enum +from typing import Optional + +import pydantic + +from craft_store.models._base_model import MarshableModel + + +class ArchitectureList(pydantic.ConstrainedList): + """A list of architectures.""" + + __args__ = (str,) + item_type = str + min_items = 1 + unique_items = True + + +class CharmResourceType(str, Enum): + """Resource types for OCI images.""" + + OCI_IMAGE = "oci-image" + FILE = "file" + + +class CharmResourceBase(MarshableModel): + """A base for a charm resource.""" + + name: str = "all" + channel: str = "all" + architectures: ArchitectureList = ArchitectureList(["all"]) + + +class CharmResourceBaseList(pydantic.ConstrainedList): + """A list of charm resource bases.""" + + __args__ = (CharmResourceBase,) + item_type = CharmResourceBase + min_items = 1 + unique_items = True + + +class CharmResourceRevision(MarshableModel): + """A basic resource revision.""" + + bases: CharmResourceBaseList + created_at: datetime.datetime + name: str + revision: int + sha256: str + sha3_384: str + sha384: str + sha512: str + size: pydantic.ByteSize + type: CharmResourceType + updated_at: Optional[datetime.datetime] = None + updated_by: Optional[str] = None diff --git a/tests/integration/test_list_resource_revisions.py b/tests/integration/test_list_resource_revisions.py new file mode 100644 index 0000000..5f78036 --- /dev/null +++ b/tests/integration/test_list_resource_revisions.py @@ -0,0 +1,61 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2023 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +"""Tests for list_releases.""" +import datetime +from typing import cast + +import pydantic +from craft_store.models.resource_revision_model import ( + CharmResourceBase, + CharmResourceBaseList, + CharmResourceRevision, + CharmResourceType, +) + +from .conftest import needs_charmhub_credentials + + +@needs_charmhub_credentials() +def test_charm_list_resource_revisions(charm_client, charmhub_charm_name): + revisions = charm_client.list_resource_revisions(charmhub_charm_name, "empty-file") + + assert len(revisions) >= 1 + assert isinstance(revisions[-1], CharmResourceRevision) + + actual = cast(CharmResourceRevision, revisions[-1]) + + # Greater than or equal to in order to allow someone to replicate this + # integration test themselves. + assert actual.created_at >= datetime.datetime( + 2023, 12, 1, tzinfo=datetime.timezone.utc + ) + assert actual.revision >= 1 + + expected = CharmResourceRevision( + name="empty-file", + bases=CharmResourceBaseList([CharmResourceBase()]), + type=CharmResourceType.FILE, + # These values are for an empty file. + size=pydantic.ByteSize(0), + sha256="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha384="38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b", + sha3_384="0c63a75b845e4f7d01107d852e4c2485c51a50aaaa94fc61995e71bbee983a2ac3713831264adb47fb6bd1e058d5f004", + sha512="cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", + # Copy the actual revision properties. + created_at=actual.created_at, + revision=actual.revision, + ) + assert actual == expected diff --git a/tests/unit/test_base_client.py b/tests/unit/test_base_client.py index cdfc24e..e067ea9 100644 --- a/tests/unit/test_base_client.py +++ b/tests/unit/test_base_client.py @@ -18,12 +18,19 @@ import datetime from unittest.mock import Mock +import pydantic import pytest import requests from craft_store import BaseClient, endpoints from craft_store.models import AccountModel, RegisteredNameModel from craft_store.models._charm_model import CharmBaseModel from craft_store.models._snap_models import Confinement, Grade, Type +from craft_store.models.resource_revision_model import ( + CharmResourceBase, + CharmResourceBaseList, + CharmResourceRevision, + CharmResourceType, +) from craft_store.models.revisions_model import ( CharmRevisionModel, GitRevisionModel, @@ -215,6 +222,68 @@ def test_list_revisions(charm_client, content, expected): assert actual == expected +@pytest.mark.parametrize( + ("content", "expected"), + [ + pytest.param(b'{"revisions":[]}', [], id="empty"), + pytest.param( + b"""{"revisions":[{ + "bases": [{"name": "all", "channel": "all", "architectures": ["all"]}], + "created-at": "1970-01-01T00:00:00", + "name": "resource", + "revision": 1, + "sha256": "a-sha256", + "sha3-384": "a 384-bit sha3", + "sha384": "a sha384", + "sha512": "a sha512", + "size": 17, + "type": "file", + "updated-at": "2020-03-14T00:00:00", + "updated-by": "lengau" + }]}""", + [ + CharmResourceRevision( + bases=CharmResourceBaseList([CharmResourceBase()]), + created_at=datetime.datetime(1970, 1, 1), + name="resource", + revision=1, + sha256="a-sha256", + sha3_384="a 384-bit sha3", + sha384="a sha384", + sha512="a sha512", + size=pydantic.ByteSize(17), + type=CharmResourceType.FILE, + updated_at=datetime.datetime(2020, 3, 14), + updated_by="lengau", + ) + ], + ), + ], +) +def test_list_resource_revisions_success(charm_client, content, expected): + charm_client.http_client.request.return_value = response = requests.Response() + response._content = content + + actual = charm_client.list_resource_revisions("my-charm", "resource") + + assert actual == expected + + +def test_list_resource_revisions_not_implemented(): + """list_resource_revisions is not implemented for non-charm namespaces.""" + client = ConcreteTestClient( + base_url="https://staging.example.com", + storage_base_url="https://storage.staging.example.com", + endpoints=endpoints.SNAP_STORE, + application_name="testcraft", + user_agent="craft-store unit tests, should not be hitting a real server", + ) + client.http_client = Mock(spec=client.http_client) + + with pytest.raises(NotImplementedError): + client.list_resource_revisions("my-snap", "my-resource") + + @pytest.mark.parametrize( ("name", "entity_type", "private", "team", "expected_json"), [ From 2bbe7f8e2d4017df1ec66d1832178e600508cd0a Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 6 Dec 2023 14:57:55 -0500 Subject: [PATCH 2/2] fix: pr comments --- craft_store/models/resource_revision_model.py | 28 +++---------- .../test_list_resource_revisions.py | 5 ++- tests/unit/test_base_client.py | 42 ++++++++++++++++++- 3 files changed, 48 insertions(+), 27 deletions(-) diff --git a/craft_store/models/resource_revision_model.py b/craft_store/models/resource_revision_model.py index d0712af..fb42729 100644 --- a/craft_store/models/resource_revision_model.py +++ b/craft_store/models/resource_revision_model.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022 Canonical Ltd. +# Copyright 2023 Canonical Ltd. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -16,22 +16,13 @@ """Resource revision response models for the Store.""" import datetime from enum import Enum -from typing import Optional +from typing import List, Optional, Union import pydantic from craft_store.models._base_model import MarshableModel -class ArchitectureList(pydantic.ConstrainedList): - """A list of architectures.""" - - __args__ = (str,) - item_type = str - min_items = 1 - unique_items = True - - class CharmResourceType(str, Enum): """Resource types for OCI images.""" @@ -44,22 +35,13 @@ class CharmResourceBase(MarshableModel): name: str = "all" channel: str = "all" - architectures: ArchitectureList = ArchitectureList(["all"]) - - -class CharmResourceBaseList(pydantic.ConstrainedList): - """A list of charm resource bases.""" - - __args__ = (CharmResourceBase,) - item_type = CharmResourceBase - min_items = 1 - unique_items = True + architectures: List[str] = ["all"] class CharmResourceRevision(MarshableModel): """A basic resource revision.""" - bases: CharmResourceBaseList + bases: List[CharmResourceBase] created_at: datetime.datetime name: str revision: int @@ -68,6 +50,6 @@ class CharmResourceRevision(MarshableModel): sha384: str sha512: str size: pydantic.ByteSize - type: CharmResourceType + type: Union[CharmResourceType, str] updated_at: Optional[datetime.datetime] = None updated_by: Optional[str] = None diff --git a/tests/integration/test_list_resource_revisions.py b/tests/integration/test_list_resource_revisions.py index 5f78036..e2aa0ea 100644 --- a/tests/integration/test_list_resource_revisions.py +++ b/tests/integration/test_list_resource_revisions.py @@ -20,7 +20,6 @@ import pydantic from craft_store.models.resource_revision_model import ( CharmResourceBase, - CharmResourceBaseList, CharmResourceRevision, CharmResourceType, ) @@ -46,7 +45,7 @@ def test_charm_list_resource_revisions(charm_client, charmhub_charm_name): expected = CharmResourceRevision( name="empty-file", - bases=CharmResourceBaseList([CharmResourceBase()]), + bases=[CharmResourceBase()], type=CharmResourceType.FILE, # These values are for an empty file. size=pydantic.ByteSize(0), @@ -57,5 +56,7 @@ def test_charm_list_resource_revisions(charm_client, charmhub_charm_name): # Copy the actual revision properties. created_at=actual.created_at, revision=actual.revision, + updated_at=actual.updated_at, + updated_by=actual.updated_by, ) assert actual == expected diff --git a/tests/unit/test_base_client.py b/tests/unit/test_base_client.py index e067ea9..b0862b2 100644 --- a/tests/unit/test_base_client.py +++ b/tests/unit/test_base_client.py @@ -27,7 +27,6 @@ from craft_store.models._snap_models import Confinement, Grade, Type from craft_store.models.resource_revision_model import ( CharmResourceBase, - CharmResourceBaseList, CharmResourceRevision, CharmResourceType, ) @@ -243,7 +242,7 @@ def test_list_revisions(charm_client, content, expected): }]}""", [ CharmResourceRevision( - bases=CharmResourceBaseList([CharmResourceBase()]), + bases=[CharmResourceBase()], created_at=datetime.datetime(1970, 1, 1), name="resource", revision=1, @@ -258,6 +257,45 @@ def test_list_revisions(charm_client, content, expected): ) ], ), + # Invalid data from the store that we should accept anyway. + pytest.param( + b"""{"revisions":[{ + "bases": [ + {"name": "all", "channel": "all", "architectures": ["all", "all"]}, + {"name": "all", "channel": "all", "architectures": ["all", "all"]} + ], + "created-at": "1970-01-01T00:00:00", + "name": "", + "revision": -1, + "sha256": "", + "sha3-384": "", + "sha384": "", + "sha512": "", + "size": 0, + "type": "invalid", + "updated-at": "2020-03-14T00:00:00", + "updated-by": "" + }]}""", + [ + CharmResourceRevision( + bases=[ + CharmResourceBase(architectures=["all", "all"]), + CharmResourceBase(architectures=["all", "all"]), + ], + created_at=datetime.datetime(1970, 1, 1), + name="", + revision=-1, + sha256="", + sha3_384="", + sha384="", + sha512="", + size=pydantic.ByteSize(0), + type="invalid", + updated_at=datetime.datetime(2020, 3, 14), + updated_by="", + ), + ], + ), ], ) def test_list_resource_revisions_success(charm_client, content, expected):