From 026b78dcd173a4a2480e66ede9d6830b75d441b0 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 1 Aug 2024 16:03:34 -0400 Subject: [PATCH] build(deps)!: update to pydantic 2 (#193) --- craft_store/base_client.py | 8 +++- craft_store/creds.py | 12 +++--- craft_store/endpoints.py | 4 +- craft_store/models/_base_model.py | 28 ++++++------- craft_store/models/_charm_model.py | 2 +- .../models/_common_list_releases_model.py | 8 ++-- .../models/charm_list_releases_model.py | 4 +- craft_store/models/registered_name_model.py | 9 ++++- craft_store/models/release_request_model.py | 2 +- craft_store/models/resource_revision_model.py | 40 ++++++++++++------- craft_store/models/revisions_model.py | 8 ++-- .../models/snap_list_releases_model.py | 2 +- craft_store/models/track_guardrail_model.py | 16 ++++++-- craft_store/models/track_model.py | 16 ++++++-- pyproject.toml | 2 +- tests/unit/conftest.py | 12 +++--- .../unit/models/test_registered_name_model.py | 24 ++++++----- .../models/test_resource_revision_model.py | 10 ++--- .../unit/models/test_track_guardrail_model.py | 6 +-- tests/unit/models/test_track_model.py | 4 +- tests/unit/test_base_client.py | 8 +++- 21 files changed, 135 insertions(+), 90 deletions(-) diff --git a/craft_store/base_client.py b/craft_store/base_client.py index 89258eb..27c1bc6 100644 --- a/craft_store/base_client.py +++ b/craft_store/base_client.py @@ -325,7 +325,9 @@ def push_resource( if resource_type: request_model["type"] = resource_type if bases: - request_model["bases"] = [base.dict(skip_defaults=False) for base in bases] + request_model["bases"] = [ + base.model_dump(exclude_defaults=False) for base in bases + ] response = self.request("POST", endpoint, json=request_model) response_model = response.json() @@ -381,7 +383,9 @@ def update_resource_revisions( ) endpoint = f"/v1/{namespace}/{name}/resources/{resource_name}/revisions" - body = {"resource-revision-updates": [update.dict() for update in updates]} + body = { + "resource-revision-updates": [update.model_dump() for update in updates] + } response = self.request("PATCH", self._base_url + endpoint, json=body).json() diff --git a/craft_store/creds.py b/craft_store/creds.py index 3026e1f..c958538 100644 --- a/craft_store/creds.py +++ b/craft_store/creds.py @@ -31,14 +31,14 @@ class CandidModel(BaseModel): token_type: Literal["macaroon"] = Field("macaroon", alias="t") value: str = Field(..., alias="v") - def marshal(self) -> dict[str, Any]: + def marshal(self) -> dict[str, str]: """Create a dictionary containing the Candid credentials.""" - return self.dict(by_alias=True) + return self.model_dump(by_alias=True) @classmethod def unmarshal(cls, data: dict[str, Any]) -> "CandidModel": """Create Candid model from dictionary data.""" - return cls(**data) + return cls.model_validate(data) def marshal_candid_credentials(candid_creds: str) -> str: @@ -106,7 +106,7 @@ class UbuntuOneMacaroons(BaseModel): def with_discharge(self, discharge: str) -> "UbuntuOneMacaroons": """Create a copy of this UbuntuOneMacaroons with a different discharge macaroon.""" - return self.copy(update={"d": discharge}) + return self.model_copy(update={"d": discharge}) class UbuntuOneModel(BaseModel): @@ -117,12 +117,12 @@ class UbuntuOneModel(BaseModel): def marshal(self) -> dict[str, Any]: """Create a dictionary containing the Ubuntu One credentials.""" - return self.dict(by_alias=True) + return self.model_dump(by_alias=True) @classmethod def unmarshal(cls, data: dict[str, Any]) -> "UbuntuOneModel": """Create Candid model from dictionary data.""" - return cls(**data) + return cls.model_validate(data) def marshal_u1_credentials(u1_creds: UbuntuOneMacaroons) -> str: diff --git a/craft_store/endpoints.py b/craft_store/endpoints.py index 957b492..3ee6fff 100644 --- a/craft_store/endpoints.py +++ b/craft_store/endpoints.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2021-2022 Canonical Ltd. +# Copyright 2021-2022,2024 Canonical Ltd. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -145,7 +145,7 @@ def get_token_request( packages: Sequence[Package] | None = None, ) -> dict[str, Any]: expires = ( - datetime.utcnow().replace(microsecond=0, tzinfo=timezone.utc) + datetime.now(tz=timezone.utc).replace(microsecond=0) + timedelta(seconds=ttl) ).isoformat() diff --git a/craft_store/models/_base_model.py b/craft_store/models/_base_model.py index 13d5fdc..777983e 100644 --- a/craft_store/models/_base_model.py +++ b/craft_store/models/_base_model.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022 Canonical Ltd. +# Copyright 2022,2024 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,26 +16,24 @@ """BaseModel with marshaling capabilities.""" -from typing import Any, TypeVar +from typing import Any -from pydantic import BaseModel - -Model = TypeVar("Model") +from pydantic import BaseModel, ConfigDict +from typing_extensions import Self class MarshableModel(BaseModel): """A BaseModel that can be marshaled and unmarshaled.""" - class Config: # pylint: disable=too-few-public-methods - """Pydantic model configuration.""" - - validate_assignment = True - allow_mutation = False - alias_generator = lambda s: s.replace("_", "-") # noqa: E731 - allow_population_by_field_name = True + model_config = ConfigDict( + validate_assignment=True, + frozen=True, + alias_generator=lambda s: s.replace("_", "-"), + populate_by_name=True, + ) @classmethod - def unmarshal(cls: type[Model], data: dict[str, Any]) -> Model: + def unmarshal(cls, data: dict[str, Any]) -> Self: """Create and populate a new ``MarshableModel`` from a dict. The unmarshal method validates entries in the input dictionary, populating @@ -50,7 +48,7 @@ def unmarshal(cls: type[Model], data: dict[str, Any]) -> Model: if not isinstance(data, dict): raise TypeError("part data is not a dictionary") - return cls(**data) + return cls.model_validate(data) def marshal(self) -> dict[str, Any]: """Create a dictionary containing the part specification data. @@ -58,4 +56,4 @@ def marshal(self) -> dict[str, Any]: :return: The newly created dictionary. """ - return self.dict(by_alias=True, exclude_unset=True) + return self.model_dump(mode="json", by_alias=True, exclude_unset=True) diff --git a/craft_store/models/_charm_model.py b/craft_store/models/_charm_model.py index 7931cd1..99d7472 100644 --- a/craft_store/models/_charm_model.py +++ b/craft_store/models/_charm_model.py @@ -34,5 +34,5 @@ class ResourceModel(MarshableModel): """Resource entries for the channel-map entry from the list_releases endpoint.""" name: str - revision: int | None + revision: int | None = None type: str diff --git a/craft_store/models/_common_list_releases_model.py b/craft_store/models/_common_list_releases_model.py index 8a9c67a..4f728b4 100644 --- a/craft_store/models/_common_list_releases_model.py +++ b/craft_store/models/_common_list_releases_model.py @@ -27,8 +27,8 @@ class ProgressiveModel(MarshableModel): :param percentage: the progress of a progressive release on a channel. """ - paused: bool | None - percentage: float | None + paused: bool | None = None + percentage: float | None = None class ChannelsModel(MarshableModel): @@ -41,8 +41,8 @@ class ChannelsModel(MarshableModel): :param track: the channel track. """ - branch: str | None - fallback: str | None + branch: str | None = None + fallback: str | None = None name: str risk: str track: str diff --git a/craft_store/models/charm_list_releases_model.py b/craft_store/models/charm_list_releases_model.py index a4ca755..00e7f48 100644 --- a/craft_store/models/charm_list_releases_model.py +++ b/craft_store/models/charm_list_releases_model.py @@ -29,7 +29,7 @@ class ChannelMapModel(MarshableModel): base: CharmBaseModel channel: str - expiration_date: datetime | None + expiration_date: datetime | None = None progressive: ProgressiveModel resources: list[ResourceModel] revision: int @@ -41,7 +41,7 @@ class RevisionModel(MarshableModel): bases: list[CharmBaseModel] created_at: datetime - errors: Any + errors: Any = None revision: int sha3_384: str size: int diff --git a/craft_store/models/registered_name_model.py b/craft_store/models/registered_name_model.py index 8967319..bf02de7 100644 --- a/craft_store/models/registered_name_model.py +++ b/craft_store/models/registered_name_model.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2023 Canonical Ltd. +# Copyright 2023-2024 Canonical Ltd. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -17,6 +17,7 @@ """Registered Names models for the Store.""" from typing import Any, Literal +import pydantic from pydantic import AnyHttpUrl, Field from ._base_model import MarshableModel @@ -53,3 +54,9 @@ class RegisteredNameModel(MarshableModel): tracks: list[TrackModel] = Field(default_factory=list) type: str website: AnyHttpUrl | None = None + + @pydantic.field_serializer("website") + def _serialize_website(self, website: AnyHttpUrl | None) -> str | None: + if not website: + return None + return str(website) diff --git a/craft_store/models/release_request_model.py b/craft_store/models/release_request_model.py index 9e63ca2..17a3a37 100644 --- a/craft_store/models/release_request_model.py +++ b/craft_store/models/release_request_model.py @@ -29,7 +29,7 @@ class ResourceModel(MarshableModel): """ name: str - revision: int | None + revision: int | None = None class ReleaseRequestModel(MarshableModel): diff --git a/craft_store/models/resource_revision_model.py b/craft_store/models/resource_revision_model.py index 9da4a86..9a35442 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 2023 Canonical Ltd. +# Copyright 2023-2024 Canonical Ltd. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -14,20 +14,31 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . """Resource revision models for the Store.""" +import collections import datetime from enum import Enum -from typing import TYPE_CHECKING +from typing import Annotated, TypeVar import pydantic from craft_store.models._base_model import MarshableModel -if TYPE_CHECKING: - RequestArchitectureList = list[str] -else: - RequestArchitectureList = pydantic.conlist( - item_type=str, min_items=1, unique_items=True - ) +T = TypeVar("T") + + +def _validate_list_is_unique(value: list[T]) -> list[T]: + value_set = set(value) + if len(value_set) == len(value): + return value + dupes = [item for item, count in collections.Counter(value).items() if count > 1] + raise ValueError(f"Duplicate values in list: {dupes}") + + +UniqueList = Annotated[ + list[T], + pydantic.AfterValidator(_validate_list_is_unique), + pydantic.Field(json_schema_extra={"uniqueItems": True}), +] class CharmResourceType(str, Enum): @@ -67,15 +78,14 @@ class RequestCharmResourceBase(MarshableModel): name: str = "all" channel: str = "all" - architectures: RequestArchitectureList = ["all"] + architectures: UniqueList[str] = pydantic.Field( + default_factory=lambda: ["all"], min_length=1 + ) -if TYPE_CHECKING: - RequestCharmResourceBaseList = list[RequestCharmResourceBase] -else: - RequestCharmResourceBaseList = pydantic.conlist( - item_type=RequestCharmResourceBase, min_items=1 - ) +RequestCharmResourceBaseList = Annotated[ + list[RequestCharmResourceBase], pydantic.Field(min_length=1) +] class CharmResourceRevisionUpdateRequest(MarshableModel): diff --git a/craft_store/models/revisions_model.py b/craft_store/models/revisions_model.py index 51fab96..98a413c 100644 --- a/craft_store/models/revisions_model.py +++ b/craft_store/models/revisions_model.py @@ -54,12 +54,12 @@ class RevisionModel(MarshableModel): def unmarshal(cls, data: dict[str, Any]) -> "RevisionModel": """Unmarshal a revision model.""" if "bases" in data: - return CharmRevisionModel.parse_obj(data) + return CharmRevisionModel.model_validate(data) if "apps" in data: - return SnapRevisionModel.parse_obj(data) + return SnapRevisionModel.model_validate(data) if "commit-id" in data: - return GitRevisionModel.parse_obj(data) - return RevisionModel.parse_obj(data) + return GitRevisionModel.model_validate(data) + return RevisionModel.model_validate(data) class GitRevisionModel(RevisionModel): diff --git a/craft_store/models/snap_list_releases_model.py b/craft_store/models/snap_list_releases_model.py index 198c3e9..d1aca56 100644 --- a/craft_store/models/snap_list_releases_model.py +++ b/craft_store/models/snap_list_releases_model.py @@ -29,7 +29,7 @@ class ChannelMapModel(MarshableModel): architecture: str channel: str - expiration_date: datetime | None + expiration_date: datetime | None = None progressive: ProgressiveModel revision: int when: datetime diff --git a/craft_store/models/track_guardrail_model.py b/craft_store/models/track_guardrail_model.py index d367a5a..2fd5c49 100644 --- a/craft_store/models/track_guardrail_model.py +++ b/craft_store/models/track_guardrail_model.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -* # -# Copyright 2023 Canonical Ltd. +# Copyright 2023-2024 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,8 +16,11 @@ # """Track guardrails for craft store packages.""" +import re from datetime import datetime -from re import Pattern +from typing import Annotated + +import pydantic from craft_store.models._base_model import MarshableModel @@ -25,5 +28,10 @@ class TrackGuardrailModel(MarshableModel): """A guardrail regular expression for tracks that can be created.""" - pattern: Pattern # type: ignore[type-arg] - created_at: datetime + pattern: re.Pattern[str] + created_at: Annotated[ # Prevents pydantic from setting UTC as "...Z" + datetime, + pydantic.WrapSerializer( + lambda dt, _: dt.isoformat(), when_used="json-unless-none" + ), + ] diff --git a/craft_store/models/track_model.py b/craft_store/models/track_model.py index 1697fe5..7d16148 100644 --- a/craft_store/models/track_model.py +++ b/craft_store/models/track_model.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -* # -# Copyright 2023 Canonical Ltd. +# Copyright 2023-2024 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,6 +16,9 @@ # """Track model for Craft Store packages.""" from datetime import datetime +from typing import Annotated + +import pydantic from craft_store.models._base_model import MarshableModel @@ -23,7 +26,12 @@ class TrackModel(MarshableModel): """A track that a package can be published on.""" - automatic_phasing_percentage: int | None - created_at: datetime + automatic_phasing_percentage: int | None = None + created_at: Annotated[ # Prevents pydantic from setting UTC as "...Z" + datetime, + pydantic.WrapSerializer( + lambda dt, _: dt.isoformat(), when_used="json-unless-none" + ), + ] name: str - version_pattern: str | None + version_pattern: str | None = None diff --git a/pyproject.toml b/pyproject.toml index c6256c9..d925902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "requests>=2.27.0", "requests-toolbelt>=1.0.0", "macaroonbakery>=1.3.0,!=1.3.3", - "pydantic>=1.10,<2.0", + "pydantic~=2.8", "pyxdg", ] classifiers = [ diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 069e404..275240d 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022 Canonical Ltd. +# Copyright 2022,2024 Canonical Ltd. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -86,12 +86,14 @@ def expires(): Provides a function for creating expected iso formatted expires datetime values. """ - now = datetime.datetime.utcnow() + now = datetime.datetime.now(tz=datetime.timezone.utc) def offset_iso_dt(seconds=0): - return (now + datetime.timedelta(seconds=seconds)).replace( - microsecond=0 - ).isoformat() + "+00:00" + return ( + (now + datetime.timedelta(seconds=seconds)) + .replace(microsecond=0) + .isoformat() + ) with patch("craft_store.endpoints.datetime", wraps=datetime.datetime) as dt_mock: dt_mock.utcnow.return_value = now diff --git a/tests/unit/models/test_registered_name_model.py b/tests/unit/models/test_registered_name_model.py index fd7d8cb..e6acc59 100644 --- a/tests/unit/models/test_registered_name_model.py +++ b/tests/unit/models/test_registered_name_model.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -* # -# Copyright 2023 Canonical Ltd. +# Copyright 2023-2024 Canonical Ltd. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -15,9 +15,8 @@ # along with this program. If not, see . # """Tests for RegisteredNameModel.""" -import re -from datetime import datetime +import pydantic_core import pytest from craft_store.models import ( AccountModel, @@ -57,7 +56,7 @@ ], "tracks": [{"created-at": "2023-03-28T18:50:44+00:00", "name": "1.0/stable"}], "type": "charm", - "website": "https://canonical.com", + "website": "https://canonical.com/", } @@ -91,7 +90,10 @@ def test_unmarshal(check, json_dict): actual.tracks, [TrackModel.unmarshal(t) for t in json_dict.get("tracks", [])] ) check.equal(actual.type, json_dict.get("type")) - check.equal(actual.website, json_dict.get("website")) + if actual.website is None: + check.is_none(json_dict.get("website")) + else: + check.equal(actual.website, pydantic_core.Url(json_dict.get("website"))) check.equal( actual.track_guardrails, [ @@ -101,7 +103,13 @@ def test_unmarshal(check, json_dict): ) -@pytest.mark.parametrize("payload", [BASIC_REGISTERED_NAME, REGISTERED_NAME_ALL_FIELDS]) +@pytest.mark.parametrize( + "payload", + [ + pytest.param(BASIC_REGISTERED_NAME, id="basic"), + pytest.param(REGISTERED_NAME_ALL_FIELDS, id="all_fields"), + ], +) def test_unmarshal_and_marshal(payload, check): marshalled = RegisteredNameModel.unmarshal(payload).marshal() not_set = [[["NOT SET"]]] @@ -115,8 +123,4 @@ def test_unmarshal_and_marshal(payload, check): expected = payload[field].lower() == "true" elif field in ("track-guardrails", "tracks"): expected = payload[field].copy() - for item in expected: - item["created-at"] = datetime.fromisoformat(item["created-at"]) - if field == "track-guardrails": - item["pattern"] = re.compile(item["pattern"]) check.equal(actual, expected) diff --git a/tests/unit/models/test_resource_revision_model.py b/tests/unit/models/test_resource_revision_model.py index 896690b..16d18df 100644 --- a/tests/unit/models/test_resource_revision_model.py +++ b/tests/unit/models/test_resource_revision_model.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2023 Canonical Ltd. +# Copyright 2023-2024 Canonical Ltd. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -22,18 +22,18 @@ @pytest.mark.parametrize( ("request_dict", "match"), [ - ({"revision": 1}, r"bases[:\s]+field required"), + ({"revision": 1}, r"bases[:\s]+Field required"), ( {"revision": 1, "bases": []}, - r"bases[:\s]+ensure this value has at least 1 item", + r"bases[:\s]+List should have at least 1 item", ), ( {"revision": 1, "bases": [{"architectures": ["all", "all"]}]}, - r"bases -> 0 -> architectures[:\s]+the list has duplicated items", + r"bases.0.architectures[:\s]+Value error, Duplicate values in list:", ), ( {"revision": 1, "bases": [{"architectures": []}]}, - r"bases -> 0 -> architectures[:\s]+ensure this value has at least 1 item", + r"bases.0.architectures[:\s]+List should have at least 1 item", ), ], ) diff --git a/tests/unit/models/test_track_guardrail_model.py b/tests/unit/models/test_track_guardrail_model.py index 18bf4a7..e33f339 100644 --- a/tests/unit/models/test_track_guardrail_model.py +++ b/tests/unit/models/test_track_guardrail_model.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -* # -# Copyright 2023 Canonical Ltd. +# Copyright 2023-2024 Canonical Ltd. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -50,5 +50,5 @@ def test_unmarshal(json_dict, expected): def test_unmarshal_and_marshal(payload, check): marshalled = TrackGuardrailModel.unmarshal(payload).marshal() - check.equal(payload["pattern"], marshalled["pattern"].pattern) - check.equal(payload["created-at"], marshalled["created-at"].isoformat()) + check.equal(payload["pattern"], marshalled["pattern"]) + check.equal(payload["created-at"], marshalled["created-at"]) diff --git a/tests/unit/models/test_track_model.py b/tests/unit/models/test_track_model.py index c7258bf..15a7e05 100644 --- a/tests/unit/models/test_track_model.py +++ b/tests/unit/models/test_track_model.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2023 Canonical Ltd. +# Copyright 2023-2024 Canonical Ltd. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -52,7 +52,7 @@ def test_unmarshal(check, json_dict): def test_unmarshal_and_marshal(payload, check): marshalled = TrackModel.unmarshal(payload).marshal() - check.equal(marshalled["created-at"].isoformat(), payload["created-at"]) + check.equal(marshalled["created-at"], payload["created-at"]) check.equal(marshalled["name"], payload["name"]) check.equal( "automatic-phasing-percentage" in marshalled, diff --git a/tests/unit/test_base_client.py b/tests/unit/test_base_client.py index e750a54..2dd4811 100644 --- a/tests/unit/test_base_client.py +++ b/tests/unit/test_base_client.py @@ -141,7 +141,11 @@ def test_list_registered_names(charm_client, content, expected): ], ], ) -def test_push_resource(charm_client, resource_type, bases): +def test_push_resource( + charm_client, + resource_type: CharmResourceType, + bases: list[RequestCharmResourceBase], +): name = "my-charm" resource_name = "my-resource" charm_client.http_client.request.return_value.json.return_value = { @@ -151,7 +155,7 @@ def test_push_resource(charm_client, resource_type, bases): request_model = { "upload-id": "I am an upload", "type": resource_type, - "bases": bases, + "bases": [b.model_dump(exclude_defaults=False) for b in bases], } status_url = charm_client.push_resource(