diff --git a/pyproject.toml b/pyproject.toml index 649ac04..46c54e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "sentry-sdk>=2.7.1", "social-auth-app-django", "unicef-security>=1.5.1", + "pydantic>=2.10.1", ] [project.scripts] celery-monitor = "country_workspace.__monitor__:run" diff --git a/src/country_workspace/config/__init__.py b/src/country_workspace/config/__init__.py index 2f59fa9..a26f635 100644 --- a/src/country_workspace/config/__init__.py +++ b/src/country_workspace/config/__init__.py @@ -100,6 +100,8 @@ class Group(Enum): "https://django-environ.readthedocs.io/en/latest/types.html#environ-env-db-url", ), "DEBUG": (bool, False, True, False, setting("debug")), + "DEDUP_ENGINE_API_TOKEN": (str, "", "", False, "Dedup engine API token"), + "DEDUP_ENGINE_API_URL": (str, "", "", False, "Dedup engine API url"), # "EMAIL_BACKEND": ( # str, # "django.core.mail.backends.smtp.EmailBackend", diff --git a/src/country_workspace/config/fragments/app.py b/src/country_workspace/config/fragments/app.py index ee93ee9..c044793 100644 --- a/src/country_workspace/config/fragments/app.py +++ b/src/country_workspace/config/fragments/app.py @@ -12,6 +12,10 @@ HOPE_API_TOKEN = env("HOPE_API_TOKEN") HOPE_API_URL = env("HOPE_API_URL") +DEDUP_ENGINE_API_TOKEN = env("DEDUP_ENGINE_API_TOKEN") +DEDUP_ENGINE_API_URL = env("DEDUP_ENGINE_API_URL") + + HH_LOOKUPS = [ "ResidenceStatus", ] diff --git a/src/country_workspace/config/fragments/constance.py b/src/country_workspace/config/fragments/constance.py index 4e8d432..6aae968 100644 --- a/src/country_workspace/config/fragments/constance.py +++ b/src/country_workspace/config/fragments/constance.py @@ -1,6 +1,8 @@ from country_workspace.config.fragments.app import ( AURORA_API_TOKEN, AURORA_API_URL, + DEDUP_ENGINE_API_TOKEN, + DEDUP_ENGINE_API_URL, HOPE_API_TOKEN, HOPE_API_URL, NEW_USER_DEFAULT_GROUP, @@ -52,6 +54,9 @@ "AURORA_API_URL": (AURORA_API_URL, "Aurora API Server address", str), "HOPE_API_TOKEN": (HOPE_API_TOKEN, "HOPE API Access Token", "write_only_input"), "HOPE_API_URL": (HOPE_API_URL, "HOPE API Server address", str), + "DEDUP_ENGINE_API_TOKEN": (DEDUP_ENGINE_API_TOKEN, "Dedup engine API Access Token", "write_only_input"), + "DEDUP_ENGINE_API_URL": (DEDUP_ENGINE_API_URL, "Dedup engine API Server address", str), + "KOBO_API_URL": ("", "Kobo API Server address", str), "KOBO_API_TOKEN": ("", "Kobo API Access Token", "write_only_input"), "KOBO_API_URL": ("", "Kobo API Server address", str), } diff --git a/src/country_workspace/config/settings.py b/src/country_workspace/config/settings.py index f3600dd..2e57945 100644 --- a/src/country_workspace/config/settings.py +++ b/src/country_workspace/config/settings.py @@ -50,6 +50,7 @@ "country_workspace.apps.Config", "country_workspace.workspaces.apps.Config", "country_workspace.contrib.aurora.apps.Config", + "country_workspace.contrib.dedup_engine.apps.Config", "country_workspace.versioning", *env("EXTRA_APPS"), ) diff --git a/src/country_workspace/contrib/dedup_engine/__init__.py b/src/country_workspace/contrib/dedup_engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/country_workspace/contrib/dedup_engine/adapters/__init__.py b/src/country_workspace/contrib/dedup_engine/adapters/__init__.py new file mode 100644 index 0000000..6280980 --- /dev/null +++ b/src/country_workspace/contrib/dedup_engine/adapters/__init__.py @@ -0,0 +1,3 @@ +from .deduplication_set import DeduplicationSetAdapter # noqa +from .duplicate import DuplicateAdapter # noqa +from .image import ImageAdapter # noqa diff --git a/src/country_workspace/contrib/dedup_engine/adapters/base.py b/src/country_workspace/contrib/dedup_engine/adapters/base.py new file mode 100644 index 0000000..8dd3071 --- /dev/null +++ b/src/country_workspace/contrib/dedup_engine/adapters/base.py @@ -0,0 +1,25 @@ +from urllib.parse import urljoin +from uuid import UUID + +from requests import Session + +from country_workspace.contrib.dedup_engine.models import DeduplicationSet, Image + + +class BaseAdapter: + def __init__(self, session: Session, base_url: str, resource: str) -> None: + self.session = session + self.base_url = urljoin(base_url, f"{resource}/") + + def prepare_url(self, *parts: str) -> str: + return urljoin(self.base_url, "/".join(parts)) + + @staticmethod + def get_entity_id(entity: DeduplicationSet | Image | UUID, id_field: str = "id") -> UUID: + if isinstance(entity, UUID): + return entity + elif isinstance(entity, (DeduplicationSet, Image)): + if not hasattr(entity, id_field): + raise AttributeError(f"Object of type {type(entity).__name__} does not have attribute '{id_field}'") + return getattr(entity, id_field) + raise TypeError(f"Expected DeduplicationSet, Image, or UUID, but got {type(entity).__name__}") diff --git a/src/country_workspace/contrib/dedup_engine/adapters/deduplication_set.py b/src/country_workspace/contrib/dedup_engine/adapters/deduplication_set.py new file mode 100644 index 0000000..9c8de7c --- /dev/null +++ b/src/country_workspace/contrib/dedup_engine/adapters/deduplication_set.py @@ -0,0 +1,43 @@ +from uuid import UUID + +from requests import Session + +from country_workspace.contrib.dedup_engine.adapters.base import BaseAdapter +from country_workspace.contrib.dedup_engine.models import DeduplicationSet, DeduplicationSetCreate + + +class DeduplicationSetAdapter(BaseAdapter): + def __init__(self, session: Session, base_url: str) -> None: + super().__init__(session, base_url, "deduplication_sets") + + def create(self, data: DeduplicationSetCreate) -> DeduplicationSet: + url = self.base_url + response = self.session.post(url, json=data.model_dump(by_alias=True)) + response.raise_for_status() + return DeduplicationSet(**response.json()) + + def destroy(self, data: DeduplicationSet | UUID) -> None: + deduplication_set_id = self.get_entity_id(data) + url = self.prepare_url(str(deduplication_set_id)) + response = self.session.delete(url) + if response.status_code != 204: + response.raise_for_status() + + def list(self) -> list[DeduplicationSet]: + response = self.session.get(self.base_url) + response.raise_for_status() + return [DeduplicationSet(**item) for item in response.json()] + + def retrieve(self, data: DeduplicationSet | UUID) -> DeduplicationSet: + deduplication_set_id = self.get_entity_id(data) + url = self.prepare_url(str(deduplication_set_id)) + response = self.session.get(url) + response.raise_for_status() + return DeduplicationSet(**response.json()) + + def process(self, data: DeduplicationSet | UUID) -> None: + deduplication_set_id = self.get_entity_id(data) + url = self.prepare_url(f"{deduplication_set_id}/process/") + response = self.session.post(url) + if response.status_code != 200: + response.raise_for_status() diff --git a/src/country_workspace/contrib/dedup_engine/adapters/duplicate.py b/src/country_workspace/contrib/dedup_engine/adapters/duplicate.py new file mode 100644 index 0000000..32e1899 --- /dev/null +++ b/src/country_workspace/contrib/dedup_engine/adapters/duplicate.py @@ -0,0 +1,20 @@ +from urllib.parse import urljoin +from uuid import UUID + +from requests import Session + +from country_workspace.contrib.dedup_engine.adapters.base import BaseAdapter +from country_workspace.contrib.dedup_engine.models import DeduplicationSet, Duplicate + + +class DuplicateAdapter(BaseAdapter): + def __init__(self, session: Session, base_url: str) -> None: + self.session = session + self.base_url = urljoin(base_url, "deduplication_sets/") + + def list(self, data: DeduplicationSet | UUID) -> list[Duplicate]: + deduplication_set_id = self.get_entity_id(data) + url = self.prepare_url(f"{deduplication_set_id}/duplicates/") + response = self.session.get(url) + response.raise_for_status() + return [Duplicate(**item) for item in response.json()] diff --git a/src/country_workspace/contrib/dedup_engine/adapters/image.py b/src/country_workspace/contrib/dedup_engine/adapters/image.py new file mode 100644 index 0000000..43c7363 --- /dev/null +++ b/src/country_workspace/contrib/dedup_engine/adapters/image.py @@ -0,0 +1,35 @@ +from urllib.parse import urljoin +from uuid import UUID + +from requests import Session + +from country_workspace.contrib.dedup_engine.adapters.base import BaseAdapter +from country_workspace.contrib.dedup_engine.models import DeduplicationSet, Image, ImageCreate + + +class ImageAdapter(BaseAdapter): + def __init__(self, session: Session, base_url: str) -> None: + self.session = session + self.base_url = urljoin(base_url, "deduplication_sets/") + + def list(self, data: DeduplicationSet | UUID) -> list[Image]: + deduplication_set_id = self.get_entity_id(data) + url = self.prepare_url(f"{deduplication_set_id}/images/") + response = self.session.get(url) + response.raise_for_status() + return [Image(**item) for item in response.json()] + + def create(self, deduplication_set: DeduplicationSet | UUID, data: ImageCreate) -> Image: + deduplication_set_id = self.get_entity_id(deduplication_set) + url = self.prepare_url(f"{deduplication_set_id}/images/") + response = self.session.post(url, json=data.model_dump(by_alias=True)) + response.raise_for_status() + return Image(**response.json()) + + def delete(self, deduplication_set: DeduplicationSet | UUID, image: Image | UUID) -> None: + deduplication_set_id = self.get_entity_id(deduplication_set) + image_id = self.get_entity_id(image) + url = self.prepare_url(f"{deduplication_set_id}/images/{image_id}/") + response = self.session.delete(url) + if response.status_code != 204: + response.raise_for_status() diff --git a/src/country_workspace/contrib/dedup_engine/apps.py b/src/country_workspace/contrib/dedup_engine/apps.py new file mode 100644 index 0000000..bf79f22 --- /dev/null +++ b/src/country_workspace/contrib/dedup_engine/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class Config(AppConfig): + name = __name__.rpartition(".")[0] + verbose_name = "Country Workspace | Dedup Engine Client" diff --git a/src/country_workspace/contrib/dedup_engine/client.py b/src/country_workspace/contrib/dedup_engine/client.py new file mode 100644 index 0000000..fa981ca --- /dev/null +++ b/src/country_workspace/contrib/dedup_engine/client.py @@ -0,0 +1,33 @@ +from requests import Session +from requests.auth import AuthBase +from requests.models import PreparedRequest + +from country_workspace.contrib.dedup_engine.adapters import DeduplicationSetAdapter, DuplicateAdapter, ImageAdapter + + +class Auth(AuthBase): + def __init__(self, token: str) -> None: + self._auth_header = f"Token {token}" + + def __call__(self, request: PreparedRequest) -> PreparedRequest: + request.headers["Authorization"] = self._auth_header + return request + + +class HDEAPIClient: + def __init__(self, *, base_url: str, token: str) -> None: + self.base_url = base_url.rstrip("/") + self.session = Session() + self.session.auth = Auth(token) + + @property + def deduplication_set(self) -> DeduplicationSetAdapter: + return DeduplicationSetAdapter(self.session, self.base_url) + + @property + def duplicate(self) -> DuplicateAdapter: + return DuplicateAdapter(self.session, self.base_url) + + @property + def image(self) -> ImageAdapter: + return ImageAdapter(self.session, self.base_url) diff --git a/src/country_workspace/contrib/dedup_engine/exceptions.py b/src/country_workspace/contrib/dedup_engine/exceptions.py new file mode 100644 index 0000000..d879cb2 --- /dev/null +++ b/src/country_workspace/contrib/dedup_engine/exceptions.py @@ -0,0 +1,4 @@ +class DedupEngineAPIBusinessError(Exception): + def __init__(self, detail: str): + self.detail = detail + super().__init__(f"Business logic error: {detail}") diff --git a/src/country_workspace/contrib/dedup_engine/models/__init__.py b/src/country_workspace/contrib/dedup_engine/models/__init__.py new file mode 100644 index 0000000..6ceb8fe --- /dev/null +++ b/src/country_workspace/contrib/dedup_engine/models/__init__.py @@ -0,0 +1,3 @@ +from .deduplication_set import DeduplicationSet, DeduplicationSetCreate # noqa +from .duplicate import Duplicate # noqa +from .image import Image, ImageCreate # noqa diff --git a/src/country_workspace/contrib/dedup_engine/models/deduplication_set.py b/src/country_workspace/contrib/dedup_engine/models/deduplication_set.py new file mode 100644 index 0000000..80b4de6 --- /dev/null +++ b/src/country_workspace/contrib/dedup_engine/models/deduplication_set.py @@ -0,0 +1,52 @@ +from datetime import datetime +from enum import Enum +from typing import Any +from uuid import UUID + +from pydantic import BaseModel + + +class DeduplicationSetStatus(Enum): + CLEAN = "Clean" + DIRTY = "Dirty" + + @property + def label(self) -> str: + return self.value + + @property + def description(self) -> str: + if self == DeduplicationSetStatus.CLEAN: + return "Deduplication set is created or already processed" + elif self == DeduplicationSetStatus.DIRTY: + return "Deduplication set needs processing" + + +class DeduplicationSetConfig(BaseModel): + name: str | None = None + settings: dict[str, Any] | None = None + + +class DeduplicationSetCreate(BaseModel): + reference_pk: str + name: str | None = None + description: str | None = None + notification_url: str | None = None + + +class DeduplicationSet(DeduplicationSetCreate): + id: UUID + state: DeduplicationSetStatus + config: DeduplicationSetConfig | None = None + created_at: datetime + updated_at: datetime | None = None + external_system: str + created_by: int + updated_by: int | None = None + + model_config = { + "json_encoders": { + datetime: lambda v: v.isoformat(), + DeduplicationSetStatus: lambda v: v.label, + } + } diff --git a/src/country_workspace/contrib/dedup_engine/models/duplicate.py b/src/country_workspace/contrib/dedup_engine/models/duplicate.py new file mode 100644 index 0000000..34e213a --- /dev/null +++ b/src/country_workspace/contrib/dedup_engine/models/duplicate.py @@ -0,0 +1,13 @@ +from uuid import UUID + +from pydantic import BaseModel + + +class DuplicateReference(BaseModel): + reference_pk: UUID + + +class Duplicate(BaseModel): + first: DuplicateReference + second: DuplicateReference + score: float diff --git a/src/country_workspace/contrib/dedup_engine/models/image.py b/src/country_workspace/contrib/dedup_engine/models/image.py new file mode 100644 index 0000000..750a8a5 --- /dev/null +++ b/src/country_workspace/contrib/dedup_engine/models/image.py @@ -0,0 +1,22 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class ImageCreate(BaseModel): + reference_pk: str # perhaps better UUID, but need changes in the server + filename: str + + +class Image(ImageCreate): + id: UUID + deduplication_set: UUID + created_by: int | None = None + created_at: datetime + + model_config = { + "json_encoders": { + datetime: lambda v: v.isoformat(), + } + } diff --git a/uv.lock b/uv.lock index 57245f6..bd9ffa6 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944 }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + [[package]] name = "asgiref" version = "3.8.1" @@ -1106,6 +1115,7 @@ dependencies = [ { name = "openpyxl" }, { name = "psycopg2-binary" }, { name = "python-redis-lock", extra = ["django"] }, + { name = "pydantic" }, { name = "redis" }, { name = "sentry-sdk" }, { name = "social-auth-app-django" }, @@ -1200,6 +1210,7 @@ requires-dist = [ { name = "openpyxl", specifier = ">=3.1.5" }, { name = "psycopg2-binary", specifier = ">=2.9.9" }, { name = "python-redis-lock", extras = ["django"], specifier = ">=4.0.0" }, + { name = "pydantic", specifier = ">=2.10.1" }, { name = "redis" }, { name = "sentry-sdk", specifier = ">=2.7.1" }, { name = "social-auth-app-django" }, @@ -2018,6 +2029,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, ] +[[package]] +name = "pydantic" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/bd/7fc610993f616d2398958d0028d15eaf53bde5f80cb2edb7aa4f1feaf3a7/pydantic-2.10.1.tar.gz", hash = "sha256:a4daca2dc0aa429555e0656d6bf94873a7dc5f54ee42b1f5873d666fb3f35560", size = 783717 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/fc/fda48d347bd50a788dd2a0f318a52160f911b86fc2d8b4c86f4d7c9bceea/pydantic-2.10.1-py3-none-any.whl", hash = "sha256:a8d20db84de64cf4a7d59e899c2caf0fe9d660c7cfc482528e7020d7dd189a7e", size = 455329 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/51/2e9b3788feb2aebff2aa9dfbf060ec739b38c05c46847601134cc1fed2ea/pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", size = 1895239 }, + { url = "https://files.pythonhosted.org/packages/7b/9e/f8063952e4a7d0127f5d1181addef9377505dcce3be224263b25c4f0bfd9/pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", size = 1805070 }, + { url = "https://files.pythonhosted.org/packages/2c/9d/e1d6c4561d262b52e41b17a7ef8301e2ba80b61e32e94520271029feb5d8/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", size = 1828096 }, + { url = "https://files.pythonhosted.org/packages/be/65/80ff46de4266560baa4332ae3181fffc4488ea7d37282da1a62d10ab89a4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", size = 1857708 }, + { url = "https://files.pythonhosted.org/packages/d5/ca/3370074ad758b04d9562b12ecdb088597f4d9d13893a48a583fb47682cdf/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", size = 2037751 }, + { url = "https://files.pythonhosted.org/packages/b1/e2/4ab72d93367194317b99d051947c071aef6e3eb95f7553eaa4208ecf9ba4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", size = 2733863 }, + { url = "https://files.pythonhosted.org/packages/8a/c6/8ae0831bf77f356bb73127ce5a95fe115b10f820ea480abbd72d3cc7ccf3/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", size = 2161161 }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b2fe73241da2429400fc27ddeaa43e35562f96cf5b67499b2de52b528cad/pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", size = 1993294 }, + { url = "https://files.pythonhosted.org/packages/77/29/4bb008823a7f4cc05828198153f9753b3bd4c104d93b8e0b1bfe4e187540/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", size = 2001468 }, + { url = "https://files.pythonhosted.org/packages/f2/a9/0eaceeba41b9fad851a4107e0cf999a34ae8f0d0d1f829e2574f3d8897b0/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", size = 2091413 }, + { url = "https://files.pythonhosted.org/packages/d8/36/eb8697729725bc610fd73940f0d860d791dc2ad557faaefcbb3edbd2b349/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", size = 2154735 }, + { url = "https://files.pythonhosted.org/packages/52/e5/4f0fbd5c5995cc70d3afed1b5c754055bb67908f55b5cb8000f7112749bf/pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", size = 1833633 }, + { url = "https://files.pythonhosted.org/packages/ee/f2/c61486eee27cae5ac781305658779b4a6b45f9cc9d02c90cb21b940e82cc/pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", size = 1986973 }, + { url = "https://files.pythonhosted.org/packages/df/a6/e3f12ff25f250b02f7c51be89a294689d175ac76e1096c32bf278f29ca1e/pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", size = 1883215 }, +] + [[package]] name = "pyflakes" version = "3.2.0"