From b453de3dcb37b414fa151bcb31120a27869465db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Tue, 9 Jan 2024 23:47:25 -0300 Subject: [PATCH 01/66] Add basic Siren classes --- fastapi_hypermodel/siren.py | 77 +++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 fastapi_hypermodel/siren.py diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py new file mode 100644 index 0000000..ef6a448 --- /dev/null +++ b/fastapi_hypermodel/siren.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from enum import Enum +from typing import ( + Any, + Mapping, + Optional, + Sequence, + Union, +) + +from pydantic import BaseModel, Field + +from fastapi_hypermodel.url_type import UrlType + + +class SirenBase(BaseModel): + class_: Optional[Sequence[str]] = Field(default=None, alias="class") + title: Optional[str] = None + + +POSSIBLE_FIELDS = [ + "hidden" + "text" + "search" + "tel" + "url" + "email" + "password" + "datetime" + "date" + "month" + "week" + "time" + "datetime-local" + "number" + "range" + "color" + "checkbox" + "radio" + "file" +] +FieldType = Enum("FieldType", POSSIBLE_FIELDS) + + +class SirenFieldType(SirenBase): + name: str + type_: Optional[FieldType] = Field(default=None, alias="type") + value: Optional[str] = None + + +DEFAULT_ACTION_TYPE = "application/x-www-form-urlencoded" + + +class SirenActionType(SirenBase): + name: str + method: Optional[str] = None + href: UrlType + type_: Optional[str] = Field(default=DEFAULT_ACTION_TYPE, alias="type") + fields: Optional[Sequence[SirenFieldType]] + + +class SirenLinkType(SirenBase): + rel: Sequence[str] + href: UrlType = Field(default=UrlType()) + type_: Optional[str] = Field(default=None, alias="type") + + +class SirenEntityType(SirenBase): + properties: Optional[Mapping[str, Any]] = None + entities: Optional[Sequence[Union[SirenEmbeddedType, SirenLinkType]]] = None + links: Optional[Sequence[SirenLinkType]] = None + actions: Optional[Sequence[SirenActionType]] = None + + +class SirenEmbeddedType(SirenEntityType): + rel: str = Field() From cbf2adac3f2ed6649e9d5b3f0e75a10e4ece1c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Thu, 11 Jan 2024 21:45:06 -0300 Subject: [PATCH 02/66] Add Siren Example based on HAL's --- examples/siren/__init__.py | 4 + examples/siren/__main__.py | 4 + examples/siren/app.py | 158 +++++++++++++++++++++++++++++++++ examples/siren/data.py | 63 +++++++++++++ fastapi_hypermodel/__init__.py | 5 ++ fastapi_hypermodel/siren.py | 146 +++++++++++++++++++++++++++++- 6 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 examples/siren/__init__.py create mode 100644 examples/siren/__main__.py create mode 100644 examples/siren/app.py create mode 100644 examples/siren/data.py diff --git a/examples/siren/__init__.py b/examples/siren/__init__.py new file mode 100644 index 0000000..ffe4535 --- /dev/null +++ b/examples/siren/__init__.py @@ -0,0 +1,4 @@ +from examples.siren.app import Item, ItemSummary, Person, app +from examples.siren.data import curies, items, people + +__all__ = ["ItemSummary", "Item", "Person", "app", "items", "people", "curies"] diff --git a/examples/siren/__main__.py b/examples/siren/__main__.py new file mode 100644 index 0000000..0a2ae51 --- /dev/null +++ b/examples/siren/__main__.py @@ -0,0 +1,4 @@ +import uvicorn + +if __name__ == "__main__": + uvicorn.run("examples.siren.app:app", host="127.0.0.1", port=8000, reload=True) diff --git a/examples/siren/app.py b/examples/siren/app.py new file mode 100644 index 0000000..ba1f7dc --- /dev/null +++ b/examples/siren/app.py @@ -0,0 +1,158 @@ +from typing import Any, Optional, Sequence, cast + +from fastapi import FastAPI, HTTPException +from pydantic import Field +from pydantic.main import BaseModel + +from examples.siren.data import Item as ItemData +from examples.siren.data import Person as PersonData +from examples.siren.data import items, people +from fastapi_hypermodel import ( + LinkSet, + SirenFor, + SirenHyperModel, + SirenResponse, +) + + +class ItemSummary(SirenHyperModel): + name: str + id_: str + + links_: LinkSet = Field( + default=LinkSet({ + "self": SirenFor("read_item", {"id_": ""}), + "update": SirenFor("update_item", {"id_": ""}), + }), + alias="_links", + ) + + +class Item(ItemSummary): + description: Optional[str] = None + price: float + + +class ItemUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + price: Optional[float] = None + + +class ItemCreate(ItemUpdate): + id_: str + + +class ItemCollection(SirenHyperModel): + items: Sequence[Item] + + links_: LinkSet = Field( + default=LinkSet({ + "self": SirenFor("read_items"), + "find": SirenFor("read_item", templated=True), + "update": SirenFor("update_item", templated=True), + }), + alias="_links", + ) + + +class Person(SirenHyperModel): + name: str + id_: str + is_locked: bool + + items: Sequence[Item] + + links_: LinkSet = Field( + default=LinkSet({ + "self": SirenFor("read_person", {"id_": ""}), + "update": SirenFor("update_person", {"id_": ""}), + "add_item": SirenFor( + "put_person_items", + {"id_": ""}, + description="Add an item to this person and the items list", + condition=lambda values: not values["is_locked"], + ), + }), + alias="_links", + ) + + +class PersonCollection(SirenHyperModel): + people: Sequence[Person] + + links_: LinkSet = Field( + default=LinkSet({ + "self": SirenFor("read_people"), + "find": SirenFor( + "read_person", description="Get a particular person", templated=True + ), + "update": SirenFor( + "update_person", + description="Update a particular person", + templated=True, + ), + }), + alias="_links", + ) + + +class PersonUpdate(BaseModel): + name: Optional[str] = None + is_locked: Optional[bool] = None + + +app = FastAPI() +SirenHyperModel.init_app(app) + + +@app.get("/items", response_model=ItemCollection, response_class=SirenResponse) +def read_items() -> Any: + return items + + +@app.get("/items/{id_}", response_model=Item, response_class=SirenResponse) +def read_item(id_: str) -> Any: + return next(item for item in items["items"] if item["id_"] == id_) + + +@app.put("/items/{id_}", response_model=Item, response_class=SirenResponse) +def update_item(id_: str, item: ItemUpdate) -> Any: + base_item = next(item for item in items["items"] if item["id_"] == id_) + update_item = cast(ItemData, item.model_dump(exclude_none=True)) + base_item.update(update_item) + return base_item + + +@app.get("/people", response_model=PersonCollection, response_class=SirenResponse) +def read_people() -> Any: + return people + + +@app.get("/people/{id_}", response_model=Person, response_class=SirenResponse) +def read_person(id_: str) -> Any: + return next(person for person in people["people"] if person["id_"] == id_) + + +@app.put("/people/{id_}", response_model=Person, response_class=SirenResponse) +def update_person(id_: str, person: PersonUpdate) -> Any: + base_person = next(person for person in people["people"] if person["id_"] == id_) + update_person = cast(PersonData, person.model_dump(exclude_none=True)) + base_person.update(update_person) + return base_person + + +@app.put("/people/{id_}/items", response_model=Person, response_class=SirenResponse) +def put_person_items(id_: str, item: ItemCreate) -> Any: + complete_item = next( + (item_ for item_ in items["items"] if item_["id_"] == item.id_), + None, + ) + if not complete_item: + raise HTTPException(status_code=404, detail=f"No item found with id {item.id_}") + + base_person = next(person for person in people["people"] if person["id_"] == id_) + + base_person_items = base_person["items"] + base_person_items.append(complete_item) + return base_person diff --git a/examples/siren/data.py b/examples/siren/data.py new file mode 100644 index 0000000..59df53e --- /dev/null +++ b/examples/siren/data.py @@ -0,0 +1,63 @@ +from typing import List + +from typing_extensions import NotRequired, TypedDict + + +class Item(TypedDict): + id_: str + name: str + price: float + description: NotRequired[str] + + +Items = TypedDict("Items", {"items": List[Item]}) + +items: Items = { + "items": [ + {"id_": "item01", "name": "Foo", "price": 10.2}, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62, + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5, + }, + ] +} + +Person = TypedDict( + "Person", {"id_": str, "name": str, "is_locked": bool, "items": List[Item]} +) + + +class People(TypedDict): + people: List[Person] + + +people: People = { + "people": [ + { + "id_": "person01", + "name": "Alice", + "is_locked": False, + "items": items["items"][:2], + }, + { + "id_": "person02", + "name": "Bob", + "is_locked": True, + "items": items["items"][2:], + }, + ] +} diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index ec455cc..67e43d8 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -5,6 +5,7 @@ HyperModel, ) from .linkset import LinkSet, LinkSetType +from .siren import SirenFor, SirenForType, SirenHyperModel, SirenResponse from .url_for import UrlFor from .url_type import URL_TYPE_SCHEMA, UrlType from .utils import ( @@ -24,6 +25,10 @@ "HALForType", "HALResponse", "HalHyperModel", + "SirenFor", + "SirenForType", + "SirenHyperModel", + "SirenResponse", "LinkSet", "LinkSetType", "UrlType", diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index ef6a448..b2f4c8e 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -3,15 +3,120 @@ from enum import Enum from typing import ( Any, + Callable, + List, Mapping, Optional, Sequence, Union, ) -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, PrivateAttr, model_validator +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route +from typing_extensions import Self +from fastapi_hypermodel.hypermodel import AbstractHyperField, HasName, HyperModel from fastapi_hypermodel.url_type import UrlType +from fastapi_hypermodel.utils import get_route_from_app, resolve_param_values + + +class SirenForType(BaseModel): + href: UrlType = Field(default=UrlType()) + templated: Optional[bool] = None + title: Optional[str] = None + name: Optional[str] = None + type: Optional[str] = None + hreflang: Optional[str] = None + profile: Optional[str] = None + deprecation: Optional[str] = None + method: Optional[str] = None + description: Optional[str] = None + + def __bool__(self: Self) -> bool: + return bool(self.href) + + +class SirenFor(SirenForType, AbstractHyperField[SirenForType]): + # pylint: disable=too-many-instance-attributes + _endpoint: str = PrivateAttr() + _param_values: Mapping[str, str] = PrivateAttr() + _description: Optional[str] = PrivateAttr() + _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() + _templated: Optional[bool] = PrivateAttr() + # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal + _title: Optional[str] = PrivateAttr() + _name: Optional[str] = PrivateAttr() + _type: Optional[str] = PrivateAttr() + _hreflang: Optional[str] = PrivateAttr() + _profile: Optional[str] = PrivateAttr() + _deprecation: Optional[str] = PrivateAttr() + + def __init__( + self: Self, + endpoint: Union[HasName, str], + param_values: Optional[Mapping[str, str]] = None, + description: Optional[str] = None, + condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, + templated: Optional[bool] = None, + title: Optional[str] = None, + name: Optional[str] = None, + type_: Optional[str] = None, + hreflang: Optional[str] = None, + profile: Optional[str] = None, + deprecation: Optional[str] = None, + ) -> None: + super().__init__() + self._endpoint = ( + endpoint.__name__ if isinstance(endpoint, HasName) else endpoint + ) + self._param_values = param_values or {} + self._description = description + self._condition = condition + self._templated = templated + self._title = title + self._name = name + self._type = type_ + self._hreflang = hreflang + self._profile = profile + self._deprecation = deprecation + + def _get_uri_path( + self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] + ) -> UrlType: + if self._templated and isinstance(route, Route): + return UrlType(route.path) + + params = resolve_param_values(self._param_values, values) + return UrlType(app.url_path_for(self._endpoint, **params)) + + def __call__( + self: Self, app: Optional[Starlette], values: Mapping[str, Any] + ) -> HALForType: + if app is None: + return HALForType() + + if self._condition and not self._condition(values): + return HALForType() + + route = get_route_from_app(app, self._endpoint) + method = next(iter(route.methods), "GET") if route.methods else "GET" + + uri_path = self._get_uri_path(app, values, route) + + return HALForType( + href=uri_path, + method=method, + description=self._description, + templated=self._templated, + title=self._title, + name=self._name, + type=self._type, + hreflang=self._hreflang, + profile=self._profile, + deprecation=self._deprecation, + ) class SirenBase(BaseModel): @@ -75,3 +180,42 @@ class SirenEntityType(SirenBase): class SirenEmbeddedType(SirenEntityType): rel: str = Field() + + +class SirenHyperModel(HyperModel): + properties: Optional[Mapping[str, Any]] = None + entities: Optional[Sequence[Union[SirenEmbeddedType, SirenLinkType]]] = None + links: Optional[Sequence[SirenLinkType]] = None + actions: Optional[Sequence[SirenActionType]] = None + + @model_validator(mode="after") + def add_hypermodels_to_entities(self: Self) -> Self: + entities: List[Union[SirenEmbeddedType, SirenLinkType]] = [] + for name, field in self: + value: Sequence[Union[Any, Self]] = ( + field if isinstance(field, Sequence) else [field] + ) + + if not all(isinstance(element, SirenHyperModel) for element in value): + continue + + entities.extend(value) + delattr(self, name) + + self.embedded = entities + + if not self.embedded: + delattr(self, "entities") + + return self + + +class SirenResponse(JSONResponse): + media_type = "application/siren+json" + + def _validate(self: Self, content: Any) -> None: + pass + + def render(self: Self, content: Any) -> bytes: + self._validate(content) + return super().render(content) From b74b0e38db05a3ffb1778def0fe6fa5b88c4d2bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Fri, 12 Jan 2024 00:22:31 -0300 Subject: [PATCH 03/66] Add Support for Siren Links --- examples/siren/__init__.py | 4 +- examples/siren/app.py | 64 +++---- fastapi_hypermodel/__init__.py | 4 +- fastapi_hypermodel/hypermodel.py | 4 +- fastapi_hypermodel/siren.py | 145 ++++++++------- tests/integration/siren/conftest.py | 56 ++++++ tests/integration/siren/test_siren_items.py | 104 +++++++++++ tests/integration/siren/test_siren_people.py | 186 +++++++++++++++++++ 8 files changed, 459 insertions(+), 108 deletions(-) create mode 100644 tests/integration/siren/conftest.py create mode 100644 tests/integration/siren/test_siren_items.py create mode 100644 tests/integration/siren/test_siren_people.py diff --git a/examples/siren/__init__.py b/examples/siren/__init__.py index ffe4535..291e6ab 100644 --- a/examples/siren/__init__.py +++ b/examples/siren/__init__.py @@ -1,4 +1,4 @@ from examples.siren.app import Item, ItemSummary, Person, app -from examples.siren.data import curies, items, people +from examples.siren.data import items, people -__all__ = ["ItemSummary", "Item", "Person", "app", "items", "people", "curies"] +__all__ = ["ItemSummary", "Item", "Person", "app", "items", "people"] diff --git a/examples/siren/app.py b/examples/siren/app.py index ba1f7dc..fd584b8 100644 --- a/examples/siren/app.py +++ b/examples/siren/app.py @@ -1,14 +1,12 @@ from typing import Any, Optional, Sequence, cast from fastapi import FastAPI, HTTPException -from pydantic import Field from pydantic.main import BaseModel from examples.siren.data import Item as ItemData from examples.siren.data import Person as PersonData from examples.siren.data import items, people from fastapi_hypermodel import ( - LinkSet, SirenFor, SirenHyperModel, SirenResponse, @@ -19,12 +17,9 @@ class ItemSummary(SirenHyperModel): name: str id_: str - links_: LinkSet = Field( - default=LinkSet({ - "self": SirenFor("read_item", {"id_": ""}), - "update": SirenFor("update_item", {"id_": ""}), - }), - alias="_links", + links: Sequence[SirenFor] = ( + SirenFor("read_item", {"id_": ""}, rel=["self"]), + SirenFor("update_item", {"id_": ""}, rel=["update"]), ) @@ -46,13 +41,10 @@ class ItemCreate(ItemUpdate): class ItemCollection(SirenHyperModel): items: Sequence[Item] - links_: LinkSet = Field( - default=LinkSet({ - "self": SirenFor("read_items"), - "find": SirenFor("read_item", templated=True), - "update": SirenFor("update_item", templated=True), - }), - alias="_links", + links: Sequence[SirenFor] = ( + SirenFor("read_items", rel=["self"], class_=["item"]), + SirenFor("read_item", rel=["find"], templated=True), + SirenFor("update_item", rel=["update"], templated=True), ) @@ -63,37 +55,29 @@ class Person(SirenHyperModel): items: Sequence[Item] - links_: LinkSet = Field( - default=LinkSet({ - "self": SirenFor("read_person", {"id_": ""}), - "update": SirenFor("update_person", {"id_": ""}), - "add_item": SirenFor( - "put_person_items", - {"id_": ""}, - description="Add an item to this person and the items list", - condition=lambda values: not values["is_locked"], - ), - }), - alias="_links", + links: Sequence[SirenFor] = ( + SirenFor("read_person", {"id_": ""}), + SirenFor("update_person", {"id_": ""}), + SirenFor( + "put_person_items", + {"id_": ""}, + description="Add an item to this person and the items list", + condition=lambda values: not values["is_locked"], + ), ) class PersonCollection(SirenHyperModel): people: Sequence[Person] - links_: LinkSet = Field( - default=LinkSet({ - "self": SirenFor("read_people"), - "find": SirenFor( - "read_person", description="Get a particular person", templated=True - ), - "update": SirenFor( - "update_person", - description="Update a particular person", - templated=True, - ), - }), - alias="_links", + links: Sequence[SirenFor] = ( + SirenFor("read_people"), + SirenFor("read_person", description="Get a particular person", templated=True), + SirenFor( + "update_person", + description="Update a particular person", + templated=True, + ), ) diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index 67e43d8..adabdcb 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -5,7 +5,7 @@ HyperModel, ) from .linkset import LinkSet, LinkSetType -from .siren import SirenFor, SirenForType, SirenHyperModel, SirenResponse +from .siren import SirenFor, SirenHyperModel, SirenLinkType, SirenResponse from .url_for import UrlFor from .url_type import URL_TYPE_SCHEMA, UrlType from .utils import ( @@ -26,7 +26,7 @@ "HALResponse", "HalHyperModel", "SirenFor", - "SirenForType", + "SirenLinkType", "SirenHyperModel", "SirenResponse", "LinkSet", diff --git a/fastapi_hypermodel/hypermodel.py b/fastapi_hypermodel/hypermodel.py index 733e128..6181814 100644 --- a/fastapi_hypermodel/hypermodel.py +++ b/fastapi_hypermodel/hypermodel.py @@ -59,7 +59,9 @@ def __schema_subclasses__( return subclasses_schemas @abstractmethod - def __call__(self: Self, app: Optional[Starlette], values: Mapping[str, Any]) -> T: + def __call__( + self: Self, app: Optional[Starlette], values: Mapping[str, Any] + ) -> Optional[T]: raise NotImplementedError diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index b2f4c8e..59ccdc5 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -9,9 +9,17 @@ Optional, Sequence, Union, + cast, ) -from pydantic import BaseModel, Field, PrivateAttr, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PrivateAttr, + model_serializer, + model_validator, +) from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route @@ -22,65 +30,57 @@ from fastapi_hypermodel.utils import get_route_from_app, resolve_param_values -class SirenForType(BaseModel): - href: UrlType = Field(default=UrlType()) - templated: Optional[bool] = None +class SirenBase(BaseModel): + class_: Optional[Sequence[str]] = Field(default=None, alias="class") title: Optional[str] = None - name: Optional[str] = None - type: Optional[str] = None - hreflang: Optional[str] = None - profile: Optional[str] = None - deprecation: Optional[str] = None - method: Optional[str] = None - description: Optional[str] = None - def __bool__(self: Self) -> bool: - return bool(self.href) + +class SirenLinkType(SirenBase): + rel: Sequence[str] = Field(default_factory=list) + href: UrlType = Field(default=UrlType()) + type_: Optional[str] = Field(default=None, alias="type") + + @model_serializer + def serialize(self: Self) -> Mapping[str, Any]: + return {self.model_fields[k].alias or k: v for k, v in self if v} -class SirenFor(SirenForType, AbstractHyperField[SirenForType]): +class SirenFor(SirenLinkType, AbstractHyperField[SirenLinkType]): # pylint: disable=too-many-instance-attributes _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() - _description: Optional[str] = PrivateAttr() + _templated: bool = PrivateAttr() _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() - _templated: Optional[bool] = PrivateAttr() + # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal _title: Optional[str] = PrivateAttr() - _name: Optional[str] = PrivateAttr() _type: Optional[str] = PrivateAttr() - _hreflang: Optional[str] = PrivateAttr() - _profile: Optional[str] = PrivateAttr() - _deprecation: Optional[str] = PrivateAttr() + _rel: Sequence[str] = PrivateAttr() + _class: Optional[Sequence[str]] = PrivateAttr() def __init__( self: Self, endpoint: Union[HasName, str], param_values: Optional[Mapping[str, str]] = None, - description: Optional[str] = None, + templated: bool = False, condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, - templated: Optional[bool] = None, title: Optional[str] = None, - name: Optional[str] = None, type_: Optional[str] = None, - hreflang: Optional[str] = None, - profile: Optional[str] = None, - deprecation: Optional[str] = None, + rel: Optional[Sequence[str]] = None, + class_: Optional[Sequence[str]] = None, + **kwargs: Any, ) -> None: - super().__init__() + super().__init__(**kwargs) self._endpoint = ( endpoint.__name__ if isinstance(endpoint, HasName) else endpoint ) self._param_values = param_values or {} - self._description = description - self._condition = condition self._templated = templated + self._condition = condition self._title = title - self._name = name self._type = type_ - self._hreflang = hreflang - self._profile = profile - self._deprecation = deprecation + self._rel = rel or [] + self._class = class_ def _get_uri_path( self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] @@ -93,35 +93,25 @@ def _get_uri_path( def __call__( self: Self, app: Optional[Starlette], values: Mapping[str, Any] - ) -> HALForType: + ) -> Optional[SirenLinkType]: if app is None: - return HALForType() + return None if self._condition and not self._condition(values): - return HALForType() + return None route = get_route_from_app(app, self._endpoint) - method = next(iter(route.methods), "GET") if route.methods else "GET" uri_path = self._get_uri_path(app, values, route) - return HALForType( - href=uri_path, - method=method, - description=self._description, - templated=self._templated, - title=self._title, - name=self._name, - type=self._type, - hreflang=self._hreflang, - profile=self._profile, - deprecation=self._deprecation, - ) - - -class SirenBase(BaseModel): - class_: Optional[Sequence[str]] = Field(default=None, alias="class") - title: Optional[str] = None + # Using model_validate to avoid conflicts with keyword class + return SirenLinkType.model_validate({ + "href": uri_path, + "rel": self._rel, + "title": self._title, + "type": self._type, + "class": self._class, + }) POSSIBLE_FIELDS = [ @@ -165,12 +155,6 @@ class SirenActionType(SirenBase): fields: Optional[Sequence[SirenFieldType]] -class SirenLinkType(SirenBase): - rel: Sequence[str] - href: UrlType = Field(default=UrlType()) - type_: Optional[str] = Field(default=None, alias="type") - - class SirenEntityType(SirenBase): properties: Optional[Mapping[str, Any]] = None entities: Optional[Sequence[Union[SirenEmbeddedType, SirenLinkType]]] = None @@ -185,9 +169,44 @@ class SirenEmbeddedType(SirenEntityType): class SirenHyperModel(HyperModel): properties: Optional[Mapping[str, Any]] = None entities: Optional[Sequence[Union[SirenEmbeddedType, SirenLinkType]]] = None - links: Optional[Sequence[SirenLinkType]] = None + links_: Optional[Sequence[Self]] = Field(default=None, alias="links") actions: Optional[Sequence[SirenActionType]] = None + # This config is needed to use the Self in Embedded + model_config = ConfigDict(arbitrary_types_allowed=True) + + # @model_validator(mode="after") + # def add_properties(self: Self) -> Self: + # properties: Dict[str, Any] = {} + # for key, field in self: + # value: Sequence[Any] = ( + # field if isinstance(field, Sequence) else [field] + # ) + # if any(isinstance(value_, AbstractHyperField) for value_ in value): + # continue + + # properties[key] = value + + # delattr(self, key) + + # self.properties = properties + + # return self + + @model_validator(mode="after") + def add_links(self: Self) -> Self: + for name, value in self: + key = self.model_fields[name].alias or name + + if key != "links" or not value: + continue + + links = cast(Sequence[SirenFor], value) + + self.links = [link(self._app, vars(self)) for link in links] + + return self + @model_validator(mode="after") def add_hypermodels_to_entities(self: Self) -> Self: entities: List[Union[SirenEmbeddedType, SirenLinkType]] = [] @@ -202,9 +221,9 @@ def add_hypermodels_to_entities(self: Self) -> Self: entities.extend(value) delattr(self, name) - self.embedded = entities + self.entities = entities - if not self.embedded: + if not self.entities: delattr(self, "entities") return self diff --git a/tests/integration/siren/conftest.py b/tests/integration/siren/conftest.py new file mode 100644 index 0000000..388f917 --- /dev/null +++ b/tests/integration/siren/conftest.py @@ -0,0 +1,56 @@ +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from examples.siren import ( + ItemSummary, + Person, + app, +) +from examples.siren import ( + items as items_, +) +from examples.siren import ( + people as people_, +) +from fastapi_hypermodel import SirenHyperModel + + +@pytest.fixture() +def siren_client() -> TestClient: + SirenHyperModel.init_app(app) + + return TestClient(app=app, base_url="http://sirentestserver") + + +@pytest.fixture() +def items() -> Any: + return items_["items"] + + +@pytest.fixture(params=items_["items"]) +def item(request: Any) -> ItemSummary: + return ItemSummary(**request.param) + + +@pytest.fixture() +def people() -> Any: + return people_["people"] + + +@pytest.fixture(params=list(people_["people"])) +def person(request: Any) -> Person: + return Person(**request.param) + + +@pytest.fixture(params=[person for person in people_["people"] if person["is_locked"]]) +def locked_person(request: Any) -> Person: + return Person(**request.param) + + +@pytest.fixture( + params=[person for person in people_["people"] if not person["is_locked"]] +) +def unlocked_person(request: Any) -> Person: + return Person(**request.param) diff --git a/tests/integration/siren/test_siren_items.py b/tests/integration/siren/test_siren_items.py new file mode 100644 index 0000000..f96471c --- /dev/null +++ b/tests/integration/siren/test_siren_items.py @@ -0,0 +1,104 @@ +import uuid + +import pytest +from fastapi.testclient import TestClient + +from examples.siren import Item + + +@pytest.fixture() +def item_uri() -> str: + return "/items" + + +# @pytest.fixture() +# def find_uri_template(siren_client: TestClient, item_uri: str) -> str: + # find_uri = get_siren_link_href(siren_client.get(item_uri).json(), "find") + # assert find_uri + # return find_uri + + +# @pytest.fixture() +# def update_uri_template(siren_client: TestClient, item_uri: str) -> str: + # update_uri = get_siren_link_href(siren_client.get(item_uri).json(), "update") + # assert update_uri + # return update_uri + + +def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: + response = siren_client.get(item_uri) + content_type = response.headers.get("content-type") + assert content_type == "application/siren+json" + + +# def test_get_items(siren_client: TestClient, item_uri: str) -> None: +# response = siren_client.get(item_uri).json() + +# self_uri = get_siren_link_href(response, "self") +# assert self_uri == item_uri + +# find_uri = response.get("_links", {}).get("find", {}) +# assert find_uri.get("templated") +# assert item_uri in find_uri.get("href") + +# items = response.get("_embedded", {}).get("sc:items", []) +# assert items +# assert len(items) == 4 + + +# def test_get_item( +# siren_client: TestClient, +# find_uri_template: str, +# item_uri: str, +# item: Item, +# ) -> None: +# find_uri = item.parse_uri(find_uri_template) +# item_response = siren_client.get(find_uri).json() + +# item_href = get_siren_link_href(item_response, "self") + +# assert item_uri in item_href +# assert item.id_ in item_href +# assert item_response.get("id_") == item.id_ + + +# def test_update_item_from_uri_template( +# siren_client: TestClient, +# find_uri_template: str, +# update_uri_template: str, +# item: Item, +# ) -> None: +# find_uri = item.parse_uri(find_uri_template) +# before = siren_client.get(find_uri).json() + +# new_data = {"name": f"updated_{uuid.uuid4().hex}"} +# update_uri = item.parse_uri(update_uri_template) +# response = siren_client.put(update_uri, json=new_data).json() + +# assert response.get("name") == new_data.get("name") +# assert response.get("name") != before.get("name") + +# before_uri = get_siren_link_href(before, "self") +# after_uri = get_siren_link_href(response, "self") + +# assert before_uri == after_uri + + +# def test_update_item_from_update_uri( +# siren_client: TestClient, find_uri_template: str, item: Item +# ) -> None: +# find_uri = item.parse_uri(find_uri_template) +# before = siren_client.get(find_uri).json() + +# new_data = {"name": f"updated_{uuid.uuid4().hex}"} + +# update_uri = get_siren_link_href(before, "update") +# response = siren_client.put(update_uri, json=new_data).json() + +# assert response.get("name") == new_data.get("name") +# assert response.get("name") != before.get("name") + +# before_uri = get_siren_link_href(before, "self") +# after_uri = get_siren_link_href(response, "self") + +# assert before_uri == after_uri diff --git a/tests/integration/siren/test_siren_people.py b/tests/integration/siren/test_siren_people.py new file mode 100644 index 0000000..37b062f --- /dev/null +++ b/tests/integration/siren/test_siren_people.py @@ -0,0 +1,186 @@ +# import uuid +# from typing import Any + +# import pytest +# from fastapi.testclient import TestClient + +# from examples.hal import Person +# from fastapi_hypermodel import get_hal_link_href + + +# @pytest.fixture() +# def people_uri() -> str: +# return "/people" + + +# @pytest.fixture() +# def find_uri_template(hal_client: TestClient, people_uri: str) -> str: +# find_uri = get_hal_link_href(hal_client.get(people_uri).json(), "find") +# assert find_uri +# return find_uri + + +# @pytest.fixture() +# def update_uri_template(hal_client: TestClient, people_uri: str) -> str: +# update_uri = get_hal_link_href(hal_client.get(people_uri).json(), "update") +# assert update_uri +# return update_uri + + +# def test_people_content_type(hal_client: TestClient, people_uri: str) -> None: +# response = hal_client.get(people_uri) +# content_type = response.headers.get("content-type") +# assert content_type == "application/hal+json" + + +# def test_get_people(hal_client: TestClient, people_uri: str) -> None: +# response = hal_client.get(people_uri).json() + +# self_uri = get_hal_link_href(response, "self") +# assert self_uri == people_uri + +# find_uri = response.get("_links", {}).get("find", {}) +# assert find_uri.get("templated") +# assert people_uri in find_uri.get("href") + + +# def test_get_person( +# hal_client: TestClient, find_uri_template: str, person: Person, people_uri: str +# ) -> None: +# find_uri = person.parse_uri(find_uri_template) +# person_response = hal_client.get(find_uri).json() + +# person_href = get_hal_link_href(person_response, "self") + +# assert people_uri in person_href +# assert person.id_ in person_href +# assert person_response.get("id_") == person.id_ + +# embedded = person_response.get("_embedded") +# assert embedded + +# items = embedded.get("sc:items") +# assert items + + +# def test_update_person_from_uri_template( +# hal_client: TestClient, +# find_uri_template: str, +# update_uri_template: str, +# person: Person, +# ) -> None: +# find_uri = person.parse_uri(find_uri_template) +# before = hal_client.get(find_uri).json() + +# new_data = {"name": f"updated_{uuid.uuid4().hex}"} +# update_uri = person.parse_uri(update_uri_template) +# response = hal_client.put(update_uri, json=new_data).json() + +# assert response.get("name") == new_data.get("name") +# assert response.get("name") != before.get("name") + +# before_uri = get_hal_link_href(before, "self") +# after_uri = get_hal_link_href(response, "self") + +# assert before_uri == after_uri + + +# def test_update_person_from_update_uri( +# hal_client: TestClient, find_uri_template: str, person: Person +# ) -> None: +# find_uri = person.parse_uri(find_uri_template) +# before = hal_client.get(find_uri).json() + +# new_data = {"name": f"updated_{uuid.uuid4().hex}"} + +# update_uri = get_hal_link_href(before, "update") +# response = hal_client.put(update_uri, json=new_data).json() + +# assert response.get("name") == new_data.get("name") +# assert response.get("name") != before.get("name") + +# before_uri = get_hal_link_href(before, "self") +# after_uri = get_hal_link_href(response, "self") + +# assert before_uri == after_uri + + +# def test_get_person_items( +# hal_client: TestClient, find_uri_template: str, person: Person +# ) -> None: +# find_uri = person.parse_uri(find_uri_template) +# person_response = hal_client.get(find_uri).json() + +# person_items = person_response.get("_embedded").get("sc:items") + +# assert person_items +# assert isinstance(person_items, list) + +# first_item, *_ = person_items +# first_item_uri = get_hal_link_href(first_item, "self") +# first_item_response = hal_client.get(first_item_uri).json() + +# assert first_item == first_item_response + + +# @pytest.fixture() +# def existing_item() -> Any: +# return {"id_": "item04"} + + +# @pytest.fixture() +# def non_existing_item() -> Any: +# return {"id_": "item05"} + + +# def test_add_item_to_unlocked_person( +# hal_client: TestClient, +# find_uri_template: str, +# unlocked_person: Person, +# existing_item: Any, +# ) -> None: +# find_uri = unlocked_person.parse_uri(find_uri_template) +# before = hal_client.get(find_uri).json() +# before_items = before.get("_embedded", {}).get("sc:items", []) +# add_item_uri = get_hal_link_href(before, "add_item") + +# assert add_item_uri + +# after = hal_client.put(add_item_uri, json=existing_item).json() +# after_items = after.get("_embedded", {}).get("sc:items", []) +# assert after_items + +# lenght_before = len(before_items) +# lenght_after = len(after_items) +# assert lenght_before + 1 == lenght_after + +# assert after_items[-1].get("id_") == existing_item.get("id_") + + +# def test_add_item_to_unlocked_person_nonexisting_item( +# hal_client: TestClient, +# find_uri_template: str, +# unlocked_person: Person, +# non_existing_item: Any, +# ) -> None: +# find_uri = unlocked_person.parse_uri(find_uri_template) +# before = hal_client.get(find_uri).json() +# add_item_uri = get_hal_link_href(before, "add_item") + +# assert add_item_uri + +# response = hal_client.put(add_item_uri, json=non_existing_item) +# assert response.status_code == 404 +# assert response.json() == {"detail": "No item found with id item05"} + + +# def test_add_item_to_locked_person( +# hal_client: TestClient, +# find_uri_template: str, +# locked_person: Person, +# ) -> None: +# find_uri = locked_person.parse_uri(find_uri_template) +# before = hal_client.get(find_uri).json() +# add_item_uri = get_hal_link_href(before, "add_item") + +# assert not add_item_uri From 28bb55c477d8ee94f81ec36fa96981ee03f8b12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 13 Jan 2024 20:26:03 -0300 Subject: [PATCH 04/66] Add Support for Siren Actions --- examples/siren/app.py | 48 ++-- fastapi_hypermodel/__init__.py | 5 +- fastapi_hypermodel/siren.py | 248 +++++++++++++++++--- tests/integration/siren/test_siren_items.py | 56 +++++ 4 files changed, 306 insertions(+), 51 deletions(-) diff --git a/examples/siren/app.py b/examples/siren/app.py index fd584b8..a2b3c04 100644 --- a/examples/siren/app.py +++ b/examples/siren/app.py @@ -7,8 +7,9 @@ from examples.siren.data import Person as PersonData from examples.siren.data import items, people from fastapi_hypermodel import ( - SirenFor, + SirenActionFor, SirenHyperModel, + SirenLinkFor, SirenResponse, ) @@ -17,9 +18,12 @@ class ItemSummary(SirenHyperModel): name: str id_: str - links: Sequence[SirenFor] = ( - SirenFor("read_item", {"id_": ""}, rel=["self"]), - SirenFor("update_item", {"id_": ""}, rel=["update"]), + links: Sequence[SirenLinkFor] = ( + SirenLinkFor("read_item", {"id_": ""}, rel=["self"]), + ) + + actions: Sequence[SirenActionFor] = ( + SirenActionFor("update_item", {"id_": ""}, name="update"), ) @@ -41,10 +45,11 @@ class ItemCreate(ItemUpdate): class ItemCollection(SirenHyperModel): items: Sequence[Item] - links: Sequence[SirenFor] = ( - SirenFor("read_items", rel=["self"], class_=["item"]), - SirenFor("read_item", rel=["find"], templated=True), - SirenFor("update_item", rel=["update"], templated=True), + links: Sequence[SirenLinkFor] = (SirenLinkFor("read_items", rel=["self"]),) + + actions: Sequence[SirenActionFor] = ( + SirenActionFor("read_item", rel=["find"], templated=True, name="find"), + SirenActionFor("update_item", rel=["update"], templated=True, name="update"), ) @@ -55,14 +60,18 @@ class Person(SirenHyperModel): items: Sequence[Item] - links: Sequence[SirenFor] = ( - SirenFor("read_person", {"id_": ""}), - SirenFor("update_person", {"id_": ""}), - SirenFor( + links: Sequence[SirenLinkFor] = ( + SirenLinkFor("read_person", {"id_": ""}, rel=["self"]), + ) + + actions: Sequence[SirenActionFor] = ( + SirenActionFor("update_person", {"id_": ""}, rel=["update"]), + SirenActionFor( "put_person_items", {"id_": ""}, description="Add an item to this person and the items list", condition=lambda values: not values["is_locked"], + rel=["add_item"], ), ) @@ -70,13 +79,20 @@ class Person(SirenHyperModel): class PersonCollection(SirenHyperModel): people: Sequence[Person] - links: Sequence[SirenFor] = ( - SirenFor("read_people"), - SirenFor("read_person", description="Get a particular person", templated=True), - SirenFor( + links: Sequence[SirenLinkFor] = (SirenLinkFor("read_people", rel=["self"]),) + + actions: Sequence[SirenActionFor] = ( + SirenActionFor( + "read_person", + description="Get a particular person", + templated=True, + rel=["find"], + ), + SirenActionFor( "update_person", description="Update a particular person", templated=True, + rel=["update"], ), ) diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index adabdcb..3f842e2 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -5,7 +5,7 @@ HyperModel, ) from .linkset import LinkSet, LinkSetType -from .siren import SirenFor, SirenHyperModel, SirenLinkType, SirenResponse +from .siren import SirenActionFor, SirenLinkFor, SirenHyperModel, SirenLinkType, SirenResponse from .url_for import UrlFor from .url_type import URL_TYPE_SCHEMA, UrlType from .utils import ( @@ -25,7 +25,8 @@ "HALForType", "HALResponse", "HalHyperModel", - "SirenFor", + "SirenActionFor", + "SirenLinkFor", "SirenLinkType", "SirenHyperModel", "SirenResponse", diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 59ccdc5..3b7aee3 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -4,10 +4,12 @@ from typing import ( Any, Callable, + Dict, List, Mapping, Optional, Sequence, + Type, Union, cast, ) @@ -19,20 +21,32 @@ PrivateAttr, model_serializer, model_validator, + field_validator, ) +from pydantic.fields import FieldInfo from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route + +from fastapi.routing import APIRoute + from typing_extensions import Self from fastapi_hypermodel.hypermodel import AbstractHyperField, HasName, HyperModel from fastapi_hypermodel.url_type import UrlType -from fastapi_hypermodel.utils import get_route_from_app, resolve_param_values +from fastapi_hypermodel.utils import ( + get_route_from_app, + resolve_param_values, +) class SirenBase(BaseModel): class_: Optional[Sequence[str]] = Field(default=None, alias="class") - title: Optional[str] = None + title: Optional[str] = Field(default=None) + + @model_serializer + def serialize(self: Self) -> Mapping[str, Any]: + return {self.model_fields[k].alias or k: v for k, v in self if v} class SirenLinkType(SirenBase): @@ -40,12 +54,16 @@ class SirenLinkType(SirenBase): href: UrlType = Field(default=UrlType()) type_: Optional[str] = Field(default=None, alias="type") - @model_serializer - def serialize(self: Self) -> Mapping[str, Any]: - return {self.model_fields[k].alias or k: v for k, v in self if v} + @field_validator("rel", "href") + @classmethod + def mandatory(cls: Type[Self], value: Optional[str]) -> str: + if not value: + error_message = "Field rel and href are mandatory" + raise ValueError(error_message) + return value -class SirenFor(SirenLinkType, AbstractHyperField[SirenLinkType]): +class SirenLinkFor(SirenLinkType, AbstractHyperField[SirenLinkType]): # pylint: disable=too-many-instance-attributes _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() @@ -102,7 +120,8 @@ def __call__( route = get_route_from_app(app, self._endpoint) - uri_path = self._get_uri_path(app, values, route) + properties = values.get("properties", values) + uri_path = self._get_uri_path(app, properties, route) # Using model_validate to avoid conflicts with keyword class return SirenLinkType.model_validate({ @@ -140,19 +159,136 @@ def __call__( class SirenFieldType(SirenBase): name: str - type_: Optional[FieldType] = Field(default=None, alias="type") + type_: Optional[str] = Field(default=None, alias="type") value: Optional[str] = None -DEFAULT_ACTION_TYPE = "application/x-www-form-urlencoded" +class SirenActionType(SirenBase): + name: str = Field(default="") + method: Optional[str] = Field(default=None) + href: UrlType = Field(default=UrlType()) + type_: Optional[str] = Field(default=None, alias="type") + fields: Optional[Sequence[SirenFieldType]] = Field(default=None) + + @field_validator("name", "href") + @classmethod + def mandatory(cls: Type[Self], value: Optional[str]) -> str: + if not value: + error_message = f"Field name and href are mandatory, {value}" + raise ValueError(error_message) + return value + +class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): + # pylint: disable=too-many-instance-attributes + _endpoint: str = PrivateAttr() + _param_values: Mapping[str, str] = PrivateAttr() + _templated: bool = PrivateAttr() + _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() -class SirenActionType(SirenBase): - name: str - method: Optional[str] = None - href: UrlType - type_: Optional[str] = Field(default=DEFAULT_ACTION_TYPE, alias="type") - fields: Optional[Sequence[SirenFieldType]] + # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal + _class: Optional[Sequence[str]] = PrivateAttr() + _title: Optional[str] = PrivateAttr() + _name: Optional[str] = PrivateAttr() + _method: Optional[str] = PrivateAttr() + _type: Optional[str] = PrivateAttr() + _fields: Optional[Sequence[SirenFieldType]] = PrivateAttr() + + def __init__( + self: Self, + endpoint: Union[HasName, str], + param_values: Optional[Mapping[str, str]] = None, + templated: bool = False, + condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, + title: Optional[str] = None, + type_: Optional[str] = None, + class_: Optional[Sequence[str]] = None, + fields: Optional[Sequence[SirenFieldType]] = None, + method: Optional[str] = None, + name: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._endpoint = ( + endpoint.__name__ if isinstance(endpoint, HasName) else endpoint + ) + self._param_values = param_values or {} + self._templated = templated + self._condition = condition + self._title = title + self._type = type_ + self._fields = fields or [] + self._method = method + self._name = name + self._class = class_ + + def _get_uri_path( + self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] + ) -> UrlType: + if self._templated and isinstance(route, Route): + return UrlType(route.path) + + params = resolve_param_values(self._param_values, values) + return UrlType(app.url_path_for(self._endpoint, **params)) + + + def _compute_fields(self: Self, route: Route) -> List[SirenFieldType]: + if not isinstance(route, APIRoute): + return [] + + body_field = route.body_field + if not body_field: + return [] + + annotation = body_field.field_info.annotation + + if not annotation: + return [] + + model_fields = cast(Optional[Dict[str, FieldInfo]], annotation.model_fields) # type: ignore + if not model_fields: + return [] + + fields: List[SirenFieldType] = [] + for name, field_info in model_fields.items(): + field = SirenFieldType.model_validate({ + "name": name, + "type": repr(field_info.annotation), + "value": field_info.default, + }) + fields.append(field) + + return fields + + def __call__( + self: Self, app: Optional[Starlette], values: Mapping[str, Any] + ) -> Optional[SirenActionType]: + if app is None: + return None + + if self._condition and not self._condition(values): + return None + + route = get_route_from_app(app, self._endpoint) + + if not self._method: + self._method = next(iter(route.methods or {}), None) + + uri_path = self._get_uri_path(app, values, route) + + if not self._fields: + self._fields = self._compute_fields(route) + + # Using model_validate to avoid conflicts with class and type + return SirenActionType.model_validate({ + "href": uri_path, + "name": self._name, + "fields": self._fields, + "method": self._method, + "title": self._title, + "type": self._type, + "class": self._class, + }) class SirenEntityType(SirenBase): @@ -167,31 +303,49 @@ class SirenEmbeddedType(SirenEntityType): class SirenHyperModel(HyperModel): - properties: Optional[Mapping[str, Any]] = None + properties: Optional[Dict[str, Any]] = None entities: Optional[Sequence[Union[SirenEmbeddedType, SirenLinkType]]] = None links_: Optional[Sequence[Self]] = Field(default=None, alias="links") - actions: Optional[Sequence[SirenActionType]] = None + actions_: Optional[Sequence[SirenActionType]] = Field(default=None, alias="actions") # This config is needed to use the Self in Embedded model_config = ConfigDict(arbitrary_types_allowed=True) - # @model_validator(mode="after") - # def add_properties(self: Self) -> Self: - # properties: Dict[str, Any] = {} - # for key, field in self: - # value: Sequence[Any] = ( - # field if isinstance(field, Sequence) else [field] - # ) - # if any(isinstance(value_, AbstractHyperField) for value_ in value): - # continue + @model_validator(mode="after") + def add_properties(self: Self) -> Self: + properties = {} + for name, field in self: + value: Sequence[Any] = field if isinstance(field, Sequence) else [field] + + omit_types: Any = ( + AbstractHyperField, + SirenLinkFor, + SirenActionFor, + SirenHyperModel, + ) + if any(isinstance(value_, omit_types) for value_ in value): + continue + + built_in_types = { + "properties", + "entities", + "links_", + "actions_", + } + if name in built_in_types: + continue - # properties[key] = value + alias = self.model_fields[name].alias or name + properties[alias] = value if isinstance(field, Sequence) else field - # delattr(self, key) + delattr(self, name) + + if not self.properties: + self.properties = {} - # self.properties = properties + self.properties.update(properties) - # return self + return self @model_validator(mode="after") def add_links(self: Self) -> Self: @@ -201,9 +355,27 @@ def add_links(self: Self) -> Self: if key != "links" or not value: continue - links = cast(Sequence[SirenFor], value) + links = cast(Sequence[SirenLinkFor], value) + + self.properties = self.properties or {} - self.links = [link(self._app, vars(self)) for link in links] + self.links = [link(self._app, self.properties) for link in links] + + return self + + @model_validator(mode="after") + def add_actions(self: Self) -> Self: + for name, value in self: + key = self.model_fields[name].alias or name + + if key != "actions" or not value: + continue + + actions = cast(Sequence[SirenActionFor], value) + + self.properties = self.properties or {} + + self.actions = [action(self._app, self.properties) for action in actions] return self @@ -218,7 +390,9 @@ def add_hypermodels_to_entities(self: Self) -> Self: if not all(isinstance(element, SirenHyperModel) for element in value): continue - entities.extend(value) + rel = self.model_fields[name].alias or name + embedded = [self.as_embedded(field, rel) for field in value] + entities.extend(embedded) delattr(self, name) self.entities = entities @@ -228,6 +402,14 @@ def add_hypermodels_to_entities(self: Self) -> Self: return self + @model_serializer + def serialize(self: Self) -> Mapping[str, Any]: + return {self.model_fields[k].alias or k: v for k, v in self if v} + + @staticmethod + def as_embedded(field: SirenHyperModel, rel: str) -> SirenEmbeddedType: + return SirenEmbeddedType(rel=rel, **field.model_dump()) + class SirenResponse(JSONResponse): media_type = "application/siren+json" diff --git a/tests/integration/siren/test_siren_items.py b/tests/integration/siren/test_siren_items.py index f96471c..1b88882 100644 --- a/tests/integration/siren/test_siren_items.py +++ b/tests/integration/siren/test_siren_items.py @@ -1,3 +1,4 @@ +from typing import Any, List import uuid import pytest @@ -28,8 +29,63 @@ def item_uri() -> str: def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: response = siren_client.get(item_uri) content_type = response.headers.get("content-type") + response_json = response.json() + assert response_json + + assert not response_json.get("properties") + + links: List[Any] = response_json["links"] + assert links + assert isinstance(links, list) + assert len(links) == 1 + first, *_ = links + assert first["href"] + assert not isinstance(first["href"], list) + assert first["href"] + + actions: List[Any] = response_json["actions"] + assert actions + assert isinstance(actions, list) + assert len(actions) == 2 + first, *_ = actions + assert first["name"] + assert not isinstance(first["name"], list) + assert first["href"] + assert len(first) == 2 assert content_type == "application/siren+json" + + entities: List[Any] = response_json["entities"] + assert entities + assert isinstance(entities, list) + assert len(entities) == 4 + + first, *_ = entities + properties = first["properties"] + assert len(properties) == 4 + assert properties.get("name") + assert properties.get("id_") + assert not isinstance(properties.get("description"), list) + assert properties.get("price") + + links = first["links"] + assert isinstance(links, list) + assert len(links) == 1 + + first_link, *_ = links + assert first_link.get("rel") + assert len(first_link.get("rel")) == 1 + assert isinstance(first_link.get("rel"), list) + assert first_link.get("href") + + actions = first["actions"] + assert isinstance(actions, list) + assert len(actions) == 1 + + first_action, *_ = actions + assert first_action.get("name") + assert first_link.get("href") + assert content_type == "application/siren+json" # def test_get_items(siren_client: TestClient, item_uri: str) -> None: # response = siren_client.get(item_uri).json() From ebdd83974347d2d123c9b0d0a807d5b33b9ad33a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 14 Jan 2024 19:38:56 -0300 Subject: [PATCH 05/66] Add Field type conversion to HTML Types --- fastapi_hypermodel/siren.py | 96 +++++++++++---------- tests/integration/siren/test_siren_items.py | 20 +++-- 2 files changed, 67 insertions(+), 49 deletions(-) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 3b7aee3..dc9b853 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -1,11 +1,13 @@ from __future__ import annotations -from enum import Enum +from itertools import starmap from typing import ( Any, Callable, Dict, + Iterable, List, + Literal, Mapping, Optional, Sequence, @@ -14,22 +16,20 @@ cast, ) +from fastapi.routing import APIRoute from pydantic import ( BaseModel, ConfigDict, Field, PrivateAttr, + field_validator, model_serializer, model_validator, - field_validator, ) from pydantic.fields import FieldInfo from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route - -from fastapi.routing import APIRoute - from typing_extensions import Self from fastapi_hypermodel.hypermodel import AbstractHyperField, HasName, HyperModel @@ -133,35 +133,53 @@ def __call__( }) -POSSIBLE_FIELDS = [ - "hidden" - "text" - "search" - "tel" - "url" - "email" - "password" - "datetime" - "date" - "month" - "week" - "time" - "datetime-local" - "number" - "range" - "color" - "checkbox" - "radio" - "file" +FieldType = Literal[ + "hidden", + "text", + "search", + "tel", + "url", + "email", + "password", + "datetime", + "date", + "month", + "week", + "time", + "datetime-local", + "number", + "range", + "color", + "checkbox", + "radio", + "file", ] -FieldType = Enum("FieldType", POSSIBLE_FIELDS) class SirenFieldType(SirenBase): name: str - type_: Optional[str] = Field(default=None, alias="type") + type_: Optional[FieldType] = Field(default=None, alias="type") value: Optional[str] = None + @classmethod + def from_field_info(cls: Type[Self], name: str, field_info: FieldInfo) -> Self: + return cls.model_validate({ + "name": name, + "type": cls.parse_type(field_info.annotation), + "value": field_info.default, + }) + + @staticmethod + def parse_type(python_type: Optional[Type[Any]]) -> FieldType: + type_repr = repr(python_type) + if "str" in type_repr: + return "text" + + if "float" in type_repr or "int" in type_repr: + return "number" + + return "text" + class SirenActionType(SirenBase): name: str = Field(default="") @@ -186,7 +204,7 @@ class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): _templated: bool = PrivateAttr() _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() - # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal + # For details on the folllowing fields, check https://github.com/kevinswiber/siren _class: Optional[Sequence[str]] = PrivateAttr() _title: Optional[str] = PrivateAttr() _name: Optional[str] = PrivateAttr() @@ -231,11 +249,10 @@ def _get_uri_path( params = resolve_param_values(self._param_values, values) return UrlType(app.url_path_for(self._endpoint, **params)) - def _compute_fields(self: Self, route: Route) -> List[SirenFieldType]: if not isinstance(route, APIRoute): return [] - + body_field = route.body_field if not body_field: return [] @@ -244,21 +261,12 @@ def _compute_fields(self: Self, route: Route) -> List[SirenFieldType]: if not annotation: return [] - - model_fields = cast(Optional[Dict[str, FieldInfo]], annotation.model_fields) # type: ignore + + model_fields = cast(Optional[Dict[str, FieldInfo]], annotation.model_fields) # type: ignore if not model_fields: return [] - - fields: List[SirenFieldType] = [] - for name, field_info in model_fields.items(): - field = SirenFieldType.model_validate({ - "name": name, - "type": repr(field_info.annotation), - "value": field_info.default, - }) - fields.append(field) - - return fields + + return list(starmap(SirenFieldType.from_field_info, model_fields.items())) def __call__( self: Self, app: Optional[Starlette], values: Mapping[str, Any] @@ -270,7 +278,7 @@ def __call__( return None route = get_route_from_app(app, self._endpoint) - + if not self._method: self._method = next(iter(route.methods or {}), None) diff --git a/tests/integration/siren/test_siren_items.py b/tests/integration/siren/test_siren_items.py index 1b88882..456e59b 100644 --- a/tests/integration/siren/test_siren_items.py +++ b/tests/integration/siren/test_siren_items.py @@ -28,7 +28,10 @@ def item_uri() -> str: def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: response = siren_client.get(item_uri) + content_type = response.headers.get("content-type") + assert content_type == "application/siren+json" + response_json = response.json() assert response_json @@ -47,13 +50,21 @@ def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: assert actions assert isinstance(actions, list) assert len(actions) == 2 - first, *_ = actions + first, second, *_ = actions assert first["name"] assert not isinstance(first["name"], list) assert first["href"] - assert len(first) == 2 - assert content_type == "application/siren+json" - + assert len(first) == 3 + + assert len(second) == 4 + fields = second.get("fields") + assert fields + assert all(field.get("name") for field in fields) + types = [field.get("type") for field in fields] + assert all(types) + assert any(type_ == "text" for type_ in types) + assert any(type_ == "number" for type_ in types) + entities: List[Any] = response_json["entities"] assert entities assert isinstance(entities, list) @@ -85,7 +96,6 @@ def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: assert first_action.get("name") assert first_link.get("href") - assert content_type == "application/siren+json" # def test_get_items(siren_client: TestClient, item_uri: str) -> None: # response = siren_client.get(item_uri).json() From 72910073cb7000c3d0a4daa88bd8a68653480c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 14 Jan 2024 19:59:18 -0300 Subject: [PATCH 06/66] Prepopulare Siren Fields with values from the serialized object --- fastapi_hypermodel/siren.py | 23 +++++++++++++++------ tests/integration/siren/test_siren_items.py | 10 ++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index dc9b853..e775616 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -159,7 +159,7 @@ def __call__( class SirenFieldType(SirenBase): name: str type_: Optional[FieldType] = Field(default=None, alias="type") - value: Optional[str] = None + value: Optional[Any] = None @classmethod def from_field_info(cls: Type[Self], name: str, field_info: FieldInfo) -> Self: @@ -185,7 +185,9 @@ class SirenActionType(SirenBase): name: str = Field(default="") method: Optional[str] = Field(default=None) href: UrlType = Field(default=UrlType()) - type_: Optional[str] = Field(default=None, alias="type") + type_: Optional[str] = Field( + default="application/x-www-form-urlencoded", alias="type" + ) fields: Optional[Sequence[SirenFieldType]] = Field(default=None) @field_validator("name", "href") @@ -198,7 +200,6 @@ def mandatory(cls: Type[Self], value: Optional[str]) -> str: class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): - # pylint: disable=too-many-instance-attributes _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _templated: bool = PrivateAttr() @@ -249,7 +250,16 @@ def _get_uri_path( params = resolve_param_values(self._param_values, values) return UrlType(app.url_path_for(self._endpoint, **params)) - def _compute_fields(self: Self, route: Route) -> List[SirenFieldType]: + def _prepopulate_fields( + self: Self, fields: Sequence[SirenFieldType], values: Mapping[str, Any] + ) -> List[SirenFieldType]: + for field in fields: + field.value = values.get(field.name) or field.value + return list(fields) + + def _compute_fields( + self: Self, route: Route, values: Mapping[str, Any] + ) -> List[SirenFieldType]: if not isinstance(route, APIRoute): return [] @@ -266,7 +276,8 @@ def _compute_fields(self: Self, route: Route) -> List[SirenFieldType]: if not model_fields: return [] - return list(starmap(SirenFieldType.from_field_info, model_fields.items())) + fields = list(starmap(SirenFieldType.from_field_info, model_fields.items())) + return self._prepopulate_fields(fields, values) def __call__( self: Self, app: Optional[Starlette], values: Mapping[str, Any] @@ -285,7 +296,7 @@ def __call__( uri_path = self._get_uri_path(app, values, route) if not self._fields: - self._fields = self._compute_fields(route) + self._fields = self._compute_fields(route, values) # Using model_validate to avoid conflicts with class and type return SirenActionType.model_validate({ diff --git a/tests/integration/siren/test_siren_items.py b/tests/integration/siren/test_siren_items.py index 456e59b..ecc12b6 100644 --- a/tests/integration/siren/test_siren_items.py +++ b/tests/integration/siren/test_siren_items.py @@ -94,7 +94,15 @@ def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: first_action, *_ = actions assert first_action.get("name") - assert first_link.get("href") + assert first_action.get("href") + assert first_action.get("type") + + fields = first_action.get("fields") + assert fields + assert all(field.get("name") for field in fields) + assert all(field.get("type") for field in fields) + assert any(field.get("value") for field in fields) + # def test_get_items(siren_client: TestClient, item_uri: str) -> None: From abcc3813b38aff7e7bd1882b2e94df58299f03e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 14 Jan 2024 20:05:55 -0300 Subject: [PATCH 07/66] Ruff Formatting --- examples/siren/data.py | 13 ++- fastapi_hypermodel/__init__.py | 8 +- fastapi_hypermodel/siren.py | 118 ++++++++++---------- pyproject.toml | 1 + tests/integration/siren/test_siren_items.py | 26 ++--- 5 files changed, 85 insertions(+), 81 deletions(-) diff --git a/examples/siren/data.py b/examples/siren/data.py index 59df53e..c4f3390 100644 --- a/examples/siren/data.py +++ b/examples/siren/data.py @@ -10,7 +10,9 @@ class Item(TypedDict): description: NotRequired[str] -Items = TypedDict("Items", {"items": List[Item]}) +class Items(TypedDict): + items: List[Item] + items: Items = { "items": [ @@ -36,9 +38,12 @@ class Item(TypedDict): ] } -Person = TypedDict( - "Person", {"id_": str, "name": str, "is_locked": bool, "items": List[Item]} -) + +class Person(TypedDict): + id_: str + name: str + is_locked: bool + items: List[Item] class People(TypedDict): diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index 3f842e2..78ccac7 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -5,7 +5,13 @@ HyperModel, ) from .linkset import LinkSet, LinkSetType -from .siren import SirenActionFor, SirenLinkFor, SirenHyperModel, SirenLinkType, SirenResponse +from .siren import ( + SirenActionFor, + SirenHyperModel, + SirenLinkFor, + SirenLinkType, + SirenResponse, +) from .url_for import UrlFor from .url_type import URL_TYPE_SCHEMA, UrlType from .utils import ( diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index e775616..914c9f1 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -5,14 +5,10 @@ Any, Callable, Dict, - Iterable, - List, Literal, Mapping, Optional, Sequence, - Type, - Union, cast, ) @@ -41,8 +37,8 @@ class SirenBase(BaseModel): - class_: Optional[Sequence[str]] = Field(default=None, alias="class") - title: Optional[str] = Field(default=None) + class_: Sequence[str] | None = Field(default=None, alias="class") + title: str | None = Field(default=None) @model_serializer def serialize(self: Self) -> Mapping[str, Any]: @@ -52,11 +48,11 @@ def serialize(self: Self) -> Mapping[str, Any]: class SirenLinkType(SirenBase): rel: Sequence[str] = Field(default_factory=list) href: UrlType = Field(default=UrlType()) - type_: Optional[str] = Field(default=None, alias="type") + type_: str | None = Field(default=None, alias="type") @field_validator("rel", "href") @classmethod - def mandatory(cls: Type[Self], value: Optional[str]) -> str: + def mandatory(cls: type[Self], value: str | None) -> str: if not value: error_message = "Field rel and href are mandatory" raise ValueError(error_message) @@ -68,24 +64,24 @@ class SirenLinkFor(SirenLinkType, AbstractHyperField[SirenLinkType]): _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _templated: bool = PrivateAttr() - _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() + _condition: Callable[[Mapping[str, Any]], bool] | None = PrivateAttr() # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal - _title: Optional[str] = PrivateAttr() - _type: Optional[str] = PrivateAttr() + _title: str | None = PrivateAttr() + _type: str | None = PrivateAttr() _rel: Sequence[str] = PrivateAttr() - _class: Optional[Sequence[str]] = PrivateAttr() + _class: Sequence[str] | None = PrivateAttr() def __init__( self: Self, - endpoint: Union[HasName, str], - param_values: Optional[Mapping[str, str]] = None, + endpoint: HasName | str, + param_values: Mapping[str, str] | None = None, templated: bool = False, - condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, - title: Optional[str] = None, - type_: Optional[str] = None, - rel: Optional[Sequence[str]] = None, - class_: Optional[Sequence[str]] = None, + condition: Callable[[Mapping[str, Any]], bool] | None = None, + title: str | None = None, + type_: str | None = None, + rel: Sequence[str] | None = None, + class_: Sequence[str] | None = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -101,7 +97,7 @@ def __init__( self._class = class_ def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] + self: Self, app: Starlette, values: Mapping[str, Any], route: Route | str ) -> UrlType: if self._templated and isinstance(route, Route): return UrlType(route.path) @@ -110,8 +106,8 @@ def _get_uri_path( return UrlType(app.url_path_for(self._endpoint, **params)) def __call__( - self: Self, app: Optional[Starlette], values: Mapping[str, Any] - ) -> Optional[SirenLinkType]: + self: Self, app: Starlette | None, values: Mapping[str, Any] + ) -> SirenLinkType | None: if app is None: return None @@ -158,11 +154,11 @@ def __call__( class SirenFieldType(SirenBase): name: str - type_: Optional[FieldType] = Field(default=None, alias="type") - value: Optional[Any] = None + type_: FieldType | None = Field(default=None, alias="type") + value: Any | None = None @classmethod - def from_field_info(cls: Type[Self], name: str, field_info: FieldInfo) -> Self: + def from_field_info(cls: type[Self], name: str, field_info: FieldInfo) -> Self: return cls.model_validate({ "name": name, "type": cls.parse_type(field_info.annotation), @@ -170,7 +166,7 @@ def from_field_info(cls: Type[Self], name: str, field_info: FieldInfo) -> Self: }) @staticmethod - def parse_type(python_type: Optional[Type[Any]]) -> FieldType: + def parse_type(python_type: type[Any] | None) -> FieldType: type_repr = repr(python_type) if "str" in type_repr: return "text" @@ -183,16 +179,16 @@ def parse_type(python_type: Optional[Type[Any]]) -> FieldType: class SirenActionType(SirenBase): name: str = Field(default="") - method: Optional[str] = Field(default=None) + method: str | None = Field(default=None) href: UrlType = Field(default=UrlType()) - type_: Optional[str] = Field( + type_: str | None = Field( default="application/x-www-form-urlencoded", alias="type" ) - fields: Optional[Sequence[SirenFieldType]] = Field(default=None) + fields: Sequence[SirenFieldType] | None = Field(default=None) @field_validator("name", "href") @classmethod - def mandatory(cls: Type[Self], value: Optional[str]) -> str: + def mandatory(cls: type[Self], value: str | None) -> str: if not value: error_message = f"Field name and href are mandatory, {value}" raise ValueError(error_message) @@ -203,28 +199,28 @@ class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _templated: bool = PrivateAttr() - _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() + _condition: Callable[[Mapping[str, Any]], bool] | None = PrivateAttr() # For details on the folllowing fields, check https://github.com/kevinswiber/siren - _class: Optional[Sequence[str]] = PrivateAttr() - _title: Optional[str] = PrivateAttr() - _name: Optional[str] = PrivateAttr() - _method: Optional[str] = PrivateAttr() - _type: Optional[str] = PrivateAttr() - _fields: Optional[Sequence[SirenFieldType]] = PrivateAttr() + _class: Sequence[str] | None = PrivateAttr() + _title: str | None = PrivateAttr() + _name: str | None = PrivateAttr() + _method: str | None = PrivateAttr() + _type: str | None = PrivateAttr() + _fields: Sequence[SirenFieldType] | None = PrivateAttr() def __init__( self: Self, - endpoint: Union[HasName, str], - param_values: Optional[Mapping[str, str]] = None, + endpoint: HasName | str, + param_values: Mapping[str, str] | None = None, templated: bool = False, - condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, - title: Optional[str] = None, - type_: Optional[str] = None, - class_: Optional[Sequence[str]] = None, - fields: Optional[Sequence[SirenFieldType]] = None, - method: Optional[str] = None, - name: Optional[str] = None, + condition: Callable[[Mapping[str, Any]], bool] | None = None, + title: str | None = None, + type_: str | None = None, + class_: Sequence[str] | None = None, + fields: Sequence[SirenFieldType] | None = None, + method: str | None = None, + name: str | None = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -242,7 +238,7 @@ def __init__( self._class = class_ def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] + self: Self, app: Starlette, values: Mapping[str, Any], route: Route | str ) -> UrlType: if self._templated and isinstance(route, Route): return UrlType(route.path) @@ -252,14 +248,14 @@ def _get_uri_path( def _prepopulate_fields( self: Self, fields: Sequence[SirenFieldType], values: Mapping[str, Any] - ) -> List[SirenFieldType]: + ) -> list[SirenFieldType]: for field in fields: field.value = values.get(field.name) or field.value return list(fields) def _compute_fields( self: Self, route: Route, values: Mapping[str, Any] - ) -> List[SirenFieldType]: + ) -> list[SirenFieldType]: if not isinstance(route, APIRoute): return [] @@ -280,8 +276,8 @@ def _compute_fields( return self._prepopulate_fields(fields, values) def __call__( - self: Self, app: Optional[Starlette], values: Mapping[str, Any] - ) -> Optional[SirenActionType]: + self: Self, app: Starlette | None, values: Mapping[str, Any] + ) -> SirenActionType | None: if app is None: return None @@ -311,10 +307,10 @@ def __call__( class SirenEntityType(SirenBase): - properties: Optional[Mapping[str, Any]] = None - entities: Optional[Sequence[Union[SirenEmbeddedType, SirenLinkType]]] = None - links: Optional[Sequence[SirenLinkType]] = None - actions: Optional[Sequence[SirenActionType]] = None + properties: Mapping[str, Any] | None = None + entities: Sequence[SirenEmbeddedType | SirenLinkType] | None = None + links: Sequence[SirenLinkType] | None = None + actions: Sequence[SirenActionType] | None = None class SirenEmbeddedType(SirenEntityType): @@ -322,10 +318,10 @@ class SirenEmbeddedType(SirenEntityType): class SirenHyperModel(HyperModel): - properties: Optional[Dict[str, Any]] = None - entities: Optional[Sequence[Union[SirenEmbeddedType, SirenLinkType]]] = None - links_: Optional[Sequence[Self]] = Field(default=None, alias="links") - actions_: Optional[Sequence[SirenActionType]] = Field(default=None, alias="actions") + properties: dict[str, Any] | None = None + entities: Sequence[SirenEmbeddedType | SirenLinkType] | None = None + links_: Sequence[Self] | None = Field(default=None, alias="links") + actions_: Sequence[SirenActionType] | None = Field(default=None, alias="actions") # This config is needed to use the Self in Embedded model_config = ConfigDict(arbitrary_types_allowed=True) @@ -400,9 +396,9 @@ def add_actions(self: Self) -> Self: @model_validator(mode="after") def add_hypermodels_to_entities(self: Self) -> Self: - entities: List[Union[SirenEmbeddedType, SirenLinkType]] = [] + entities: list[SirenEmbeddedType | SirenLinkType] = [] for name, field in self: - value: Sequence[Union[Any, Self]] = ( + value: Sequence[Any | Self] = ( field if isinstance(field, Sequence) else [field] ) diff --git a/pyproject.toml b/pyproject.toml index cabdc94..adf3d3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ ignore = [ "ISC001", # Disable for compatibility with ruff-format ] extend-exclude = ["tests"] +target-version = "py38" [tool.ruff.lint] extend-select = [ diff --git a/tests/integration/siren/test_siren_items.py b/tests/integration/siren/test_siren_items.py index ecc12b6..e3850a7 100644 --- a/tests/integration/siren/test_siren_items.py +++ b/tests/integration/siren/test_siren_items.py @@ -1,11 +1,8 @@ from typing import Any, List -import uuid import pytest from fastapi.testclient import TestClient -from examples.siren import Item - @pytest.fixture() def item_uri() -> str: @@ -14,29 +11,29 @@ def item_uri() -> str: # @pytest.fixture() # def find_uri_template(siren_client: TestClient, item_uri: str) -> str: - # find_uri = get_siren_link_href(siren_client.get(item_uri).json(), "find") - # assert find_uri - # return find_uri +# find_uri = get_siren_link_href(siren_client.get(item_uri).json(), "find") +# assert find_uri +# return find_uri # @pytest.fixture() # def update_uri_template(siren_client: TestClient, item_uri: str) -> str: - # update_uri = get_siren_link_href(siren_client.get(item_uri).json(), "update") - # assert update_uri - # return update_uri +# update_uri = get_siren_link_href(siren_client.get(item_uri).json(), "update") +# assert update_uri +# return update_uri def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: response = siren_client.get(item_uri) - + content_type = response.headers.get("content-type") assert content_type == "application/siren+json" - + response_json = response.json() assert response_json assert not response_json.get("properties") - + links: List[Any] = response_json["links"] assert links assert isinstance(links, list) @@ -45,7 +42,7 @@ def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: assert first["href"] assert not isinstance(first["href"], list) assert first["href"] - + actions: List[Any] = response_json["actions"] assert actions assert isinstance(actions, list) @@ -77,7 +74,7 @@ def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: assert properties.get("id_") assert not isinstance(properties.get("description"), list) assert properties.get("price") - + links = first["links"] assert isinstance(links, list) assert len(links) == 1 @@ -104,7 +101,6 @@ def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: assert any(field.get("value") for field in fields) - # def test_get_items(siren_client: TestClient, item_uri: str) -> None: # response = siren_client.get(item_uri).json() From 990cdb63d6539bca36f72860d8a67861f73c8697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 14 Jan 2024 22:10:26 -0300 Subject: [PATCH 08/66] Add integration tests for Items --- fastapi_hypermodel/__init__.py | 4 + fastapi_hypermodel/siren.py | 20 ++- fastapi_hypermodel/utils.py | 10 ++ tests/integration/siren/test_siren_items.py | 145 +++++++++++--------- 4 files changed, 113 insertions(+), 66 deletions(-) diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index 78ccac7..aca4d4a 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -19,6 +19,8 @@ extract_value_by_name, get_hal_link_href, get_route_from_app, + get_siren_action, + get_siren_link, resolve_param_values, ) @@ -42,6 +44,8 @@ "resolve_param_values", "AbstractHyperField", "get_hal_link_href", + "get_siren_action", + "get_siren_link", "extract_value_by_name", "get_route_from_app", "URL_TYPE_SCHEMA", diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 914c9f1..25d0b8e 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -1,6 +1,7 @@ from __future__ import annotations from itertools import starmap +from string import Formatter from typing import ( Any, Callable, @@ -31,6 +32,7 @@ from fastapi_hypermodel.hypermodel import AbstractHyperField, HasName, HyperModel from fastapi_hypermodel.url_type import UrlType from fastapi_hypermodel.utils import ( + extract_value_by_name, get_route_from_app, resolve_param_values, ) @@ -181,10 +183,9 @@ class SirenActionType(SirenBase): name: str = Field(default="") method: str | None = Field(default=None) href: UrlType = Field(default=UrlType()) - type_: str | None = Field( - default="application/x-www-form-urlencoded", alias="type" - ) + type_: str | None = Field(default="application/x-www-form-urlencoded", alias="type") fields: Sequence[SirenFieldType] | None = Field(default=None) + templated: bool | None = Field(default=False) @field_validator("name", "href") @classmethod @@ -303,6 +304,7 @@ def __call__( "title": self._title, "type": self._type, "class": self._class, + "templated": self._templated, }) @@ -425,6 +427,18 @@ def serialize(self: Self) -> Mapping[str, Any]: def as_embedded(field: SirenHyperModel, rel: str) -> SirenEmbeddedType: return SirenEmbeddedType(rel=rel, **field.model_dump()) + def parse_uri(self: Self, uri_template: str) -> str: + parameters: dict[str, str] = {} + + for _, field, *_ in Formatter().parse(uri_template): + if not field: + error_message = "Empty Fields Cannot be Processed" + raise ValueError(error_message) + + parameters[field] = extract_value_by_name(self.properties, field) + + return uri_template.format(**parameters) + class SirenResponse(JSONResponse): media_type = "application/siren+json" diff --git a/fastapi_hypermodel/utils.py b/fastapi_hypermodel/utils.py index d15ba7f..6b26ee3 100644 --- a/fastapi_hypermodel/utils.py +++ b/fastapi_hypermodel/utils.py @@ -112,6 +112,16 @@ def get_hal_link_href(response: Any, link_name: str) -> Union[str, Any]: return response.get("_links", {}).get(link_name, {}).get("href", "") +def get_siren_link(response: Any, link_name: str) -> Mapping[str, Any]: + links = response.get("links", []) + return next(link for link in links if link_name in link.get("rel")) + + +def get_siren_action(response: Any, link_name: str) -> Mapping[str, Any]: + links = response.get("actions", []) + return next(link for link in links if link_name in link.get("name")) + + def get_route_from_app(app: Starlette, endpoint_function: str) -> Route: for route in app.routes: if isinstance(route, Route) and route.name == endpoint_function: diff --git a/tests/integration/siren/test_siren_items.py b/tests/integration/siren/test_siren_items.py index e3850a7..65f557a 100644 --- a/tests/integration/siren/test_siren_items.py +++ b/tests/integration/siren/test_siren_items.py @@ -1,26 +1,33 @@ from typing import Any, List +import uuid import pytest from fastapi.testclient import TestClient +from fastapi_hypermodel import get_siren_link, get_siren_action + +from examples.siren import Item + @pytest.fixture() def item_uri() -> str: return "/items" -# @pytest.fixture() -# def find_uri_template(siren_client: TestClient, item_uri: str) -> str: -# find_uri = get_siren_link_href(siren_client.get(item_uri).json(), "find") -# assert find_uri -# return find_uri +@pytest.fixture() +def find_uri_template(siren_client: TestClient, item_uri: str) -> str: + reponse = siren_client.get(item_uri).json() + find_uri = get_siren_action(reponse, "find").get("href", "") + assert find_uri + return find_uri -# @pytest.fixture() -# def update_uri_template(siren_client: TestClient, item_uri: str) -> str: -# update_uri = get_siren_link_href(siren_client.get(item_uri).json(), "update") -# assert update_uri -# return update_uri +@pytest.fixture() +def update_uri_template(siren_client: TestClient, item_uri: str) -> str: + reponse = siren_client.get(item_uri).json() + update_uri = get_siren_action(reponse, "update").get("href", "") + assert update_uri + return update_uri def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: @@ -51,9 +58,9 @@ def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: assert first["name"] assert not isinstance(first["name"], list) assert first["href"] - assert len(first) == 3 + assert len(first) == 4 - assert len(second) == 4 + assert len(second) == 5 fields = second.get("fields") assert fields assert all(field.get("name") for field in fields) @@ -101,74 +108,86 @@ def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: assert any(field.get("value") for field in fields) -# def test_get_items(siren_client: TestClient, item_uri: str) -> None: -# response = siren_client.get(item_uri).json() +def test_get_items(siren_client: TestClient, item_uri: str) -> None: + response = siren_client.get(item_uri).json() + + self_link = get_siren_link(response, "self") + assert self_link.get("href", "") == item_uri + + links = response.get("actions", []) + find_uri = next(link for link in links if "find" in link.get("name")) + assert find_uri.get("templated") + assert item_uri in find_uri.get("href") + + items = response.get("entities", []) + assert items + assert len(items) == 4 -# self_uri = get_siren_link_href(response, "self") -# assert self_uri == item_uri -# find_uri = response.get("_links", {}).get("find", {}) -# assert find_uri.get("templated") -# assert item_uri in find_uri.get("href") +def test_get_item( + siren_client: TestClient, + find_uri_template: str, + item_uri: str, + item: Item, +) -> None: + find_uri = item.parse_uri(find_uri_template) + item_response = siren_client.get(find_uri).json() -# items = response.get("_embedded", {}).get("sc:items", []) -# assert items -# assert len(items) == 4 + item_href = get_siren_link(item_response, "self").get("href", "") + assert item.properties -# def test_get_item( -# siren_client: TestClient, -# find_uri_template: str, -# item_uri: str, -# item: Item, -# ) -> None: -# find_uri = item.parse_uri(find_uri_template) -# item_response = siren_client.get(find_uri).json() + item_id = item.properties.get("id_", "") + assert item_uri in item_href + assert item_id in item_href + assert item_response.get("properties").get("id_") == item_id -# item_href = get_siren_link_href(item_response, "self") -# assert item_uri in item_href -# assert item.id_ in item_href -# assert item_response.get("id_") == item.id_ +def test_update_item_from_uri_template( + siren_client: TestClient, + find_uri_template: str, + update_uri_template: str, + item: Item, +) -> None: + find_uri = item.parse_uri(find_uri_template) + before = siren_client.get(find_uri).json() + new_data = {"name": f"updated_{uuid.uuid4().hex}"} + update_uri = item.parse_uri(update_uri_template) + response = siren_client.put(update_uri, json=new_data).json() -# def test_update_item_from_uri_template( -# siren_client: TestClient, -# find_uri_template: str, -# update_uri_template: str, -# item: Item, -# ) -> None: -# find_uri = item.parse_uri(find_uri_template) -# before = siren_client.get(find_uri).json() + name = response.get("properties", {}).get("name") + assert name == new_data.get("name") + assert name != before.get("name") -# new_data = {"name": f"updated_{uuid.uuid4().hex}"} -# update_uri = item.parse_uri(update_uri_template) -# response = siren_client.put(update_uri, json=new_data).json() + before_uri = get_siren_link(before, "self") + after_uri = get_siren_link(response, "self") -# assert response.get("name") == new_data.get("name") -# assert response.get("name") != before.get("name") + assert before_uri == after_uri -# before_uri = get_siren_link_href(before, "self") -# after_uri = get_siren_link_href(response, "self") -# assert before_uri == after_uri +def test_update_item_from_update_uri( + siren_client: TestClient, find_uri_template: str, item: Item +) -> None: + find_uri = item.parse_uri(find_uri_template) + before = siren_client.get(find_uri).json() + new_data = {"name": f"updated_{uuid.uuid4().hex}"} -# def test_update_item_from_update_uri( -# siren_client: TestClient, find_uri_template: str, item: Item -# ) -> None: -# find_uri = item.parse_uri(find_uri_template) -# before = siren_client.get(find_uri).json() + update_action = get_siren_action(before, "update") -# new_data = {"name": f"updated_{uuid.uuid4().hex}"} + uddate_method = update_action.get("method") + assert uddate_method + update_uri = update_action.get("href") + assert update_uri -# update_uri = get_siren_link_href(before, "update") -# response = siren_client.put(update_uri, json=new_data).json() + response = siren_client.request(uddate_method, update_uri, json=new_data).json() -# assert response.get("name") == new_data.get("name") -# assert response.get("name") != before.get("name") + name = response.get("properties", {}).get("name") + assert name == new_data.get("name") + assert name != before.get("properties").get("name") -# before_uri = get_siren_link_href(before, "self") -# after_uri = get_siren_link_href(response, "self") + before_uri = get_siren_link(before, "self") + after_uri = get_siren_link(response, "self") -# assert before_uri == after_uri + assert before_uri == after_uri From fc92ec5706b00281c6a8218a3d6e01bf155b24b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Mon, 15 Jan 2024 01:15:12 -0300 Subject: [PATCH 09/66] Add Siren Integration Tests --- examples/siren/app.py | 14 +- fastapi_hypermodel/siren.py | 4 +- fastapi_hypermodel/utils.py | 4 +- tests/integration/siren/test_siren_items.py | 60 ++-- tests/integration/siren/test_siren_people.py | 311 +++++++++++-------- 5 files changed, 228 insertions(+), 165 deletions(-) diff --git a/examples/siren/app.py b/examples/siren/app.py index a2b3c04..9fdcc5d 100644 --- a/examples/siren/app.py +++ b/examples/siren/app.py @@ -48,8 +48,8 @@ class ItemCollection(SirenHyperModel): links: Sequence[SirenLinkFor] = (SirenLinkFor("read_items", rel=["self"]),) actions: Sequence[SirenActionFor] = ( - SirenActionFor("read_item", rel=["find"], templated=True, name="find"), - SirenActionFor("update_item", rel=["update"], templated=True, name="update"), + SirenActionFor("read_item", templated=True, name="find"), + SirenActionFor("update_item", templated=True, name="update"), ) @@ -65,19 +65,19 @@ class Person(SirenHyperModel): ) actions: Sequence[SirenActionFor] = ( - SirenActionFor("update_person", {"id_": ""}, rel=["update"]), + SirenActionFor("update_person", {"id_": ""}, name="update"), SirenActionFor( "put_person_items", {"id_": ""}, description="Add an item to this person and the items list", condition=lambda values: not values["is_locked"], - rel=["add_item"], + name="add_item", ), ) class PersonCollection(SirenHyperModel): - people: Sequence[Person] + # people: Sequence[Person] links: Sequence[SirenLinkFor] = (SirenLinkFor("read_people", rel=["self"]),) @@ -86,13 +86,13 @@ class PersonCollection(SirenHyperModel): "read_person", description="Get a particular person", templated=True, - rel=["find"], + name="find", ), SirenActionFor( "update_person", description="Update a particular person", templated=True, - rel=["update"], + name="update", ), ) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 25d0b8e..486b62f 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -180,7 +180,7 @@ def parse_type(python_type: type[Any] | None) -> FieldType: class SirenActionType(SirenBase): - name: str = Field(default="") + name: str | None = Field(default="") method: str | None = Field(default=None) href: UrlType = Field(default=UrlType()) type_: str | None = Field(default="application/x-www-form-urlencoded", alias="type") @@ -217,7 +217,7 @@ def __init__( templated: bool = False, condition: Callable[[Mapping[str, Any]], bool] | None = None, title: str | None = None, - type_: str | None = None, + type_: str | None = "application/x-www-form-urlencoded", class_: Sequence[str] | None = None, fields: Sequence[SirenFieldType] | None = None, method: str | None = None, diff --git a/fastapi_hypermodel/utils.py b/fastapi_hypermodel/utils.py index 6b26ee3..dc6fc9b 100644 --- a/fastapi_hypermodel/utils.py +++ b/fastapi_hypermodel/utils.py @@ -114,12 +114,12 @@ def get_hal_link_href(response: Any, link_name: str) -> Union[str, Any]: def get_siren_link(response: Any, link_name: str) -> Mapping[str, Any]: links = response.get("links", []) - return next(link for link in links if link_name in link.get("rel")) + return next((link for link in links if link_name in link.get("rel")), {}) def get_siren_action(response: Any, link_name: str) -> Mapping[str, Any]: links = response.get("actions", []) - return next(link for link in links if link_name in link.get("name")) + return next((link for link in links if link_name in link.get("name")), {}) def get_route_from_app(app: Starlette, endpoint_function: str) -> Route: diff --git a/tests/integration/siren/test_siren_items.py b/tests/integration/siren/test_siren_items.py index 65f557a..b0e4722 100644 --- a/tests/integration/siren/test_siren_items.py +++ b/tests/integration/siren/test_siren_items.py @@ -1,12 +1,11 @@ -from typing import Any, List import uuid +from typing import Any, List, Mapping import pytest from fastapi.testclient import TestClient -from fastapi_hypermodel import get_siren_link, get_siren_action - from examples.siren import Item +from fastapi_hypermodel import get_siren_action, get_siren_link @pytest.fixture() @@ -15,17 +14,17 @@ def item_uri() -> str: @pytest.fixture() -def find_uri_template(siren_client: TestClient, item_uri: str) -> str: +def find_uri_template(siren_client: TestClient, item_uri: str) -> Mapping[str, str]: reponse = siren_client.get(item_uri).json() - find_uri = get_siren_action(reponse, "find").get("href", "") + find_uri = get_siren_action(reponse, "find") assert find_uri return find_uri @pytest.fixture() -def update_uri_template(siren_client: TestClient, item_uri: str) -> str: +def update_uri_template(siren_client: TestClient, item_uri: str) -> Mapping[str, str]: reponse = siren_client.get(item_uri).json() - update_uri = get_siren_action(reponse, "update").get("href", "") + update_uri = get_siren_action(reponse, "update") assert update_uri return update_uri @@ -58,9 +57,9 @@ def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: assert first["name"] assert not isinstance(first["name"], list) assert first["href"] - assert len(first) == 4 + assert len(first) == 5 - assert len(second) == 5 + assert len(second) == 6 fields = second.get("fields") assert fields assert all(field.get("name") for field in fields) @@ -126,12 +125,15 @@ def test_get_items(siren_client: TestClient, item_uri: str) -> None: def test_get_item( siren_client: TestClient, - find_uri_template: str, + find_uri_template: Mapping[str, str], item_uri: str, item: Item, ) -> None: - find_uri = item.parse_uri(find_uri_template) - item_response = siren_client.get(find_uri).json() + find_uri_href = find_uri_template.get("href", "") + find_uri = item.parse_uri(find_uri_href) + + find_method = find_uri_template.get("method", "") + item_response = siren_client.request(find_method, find_uri).json() item_href = get_siren_link(item_response, "self").get("href", "") @@ -145,16 +147,23 @@ def test_get_item( def test_update_item_from_uri_template( siren_client: TestClient, - find_uri_template: str, - update_uri_template: str, + find_uri_template: Mapping[str, str], + update_uri_template: Mapping[str, str], item: Item, ) -> None: - find_uri = item.parse_uri(find_uri_template) - before = siren_client.get(find_uri).json() + find_uri_href = find_uri_template.get("href", "") + find_uri = item.parse_uri(find_uri_href) + + find_method = find_uri_template.get("method", "") + before = siren_client.request(find_method, find_uri).json() new_data = {"name": f"updated_{uuid.uuid4().hex}"} - update_uri = item.parse_uri(update_uri_template) - response = siren_client.put(update_uri, json=new_data).json() + + update_uri_href = update_uri_template.get("href", "") + update_uri = item.parse_uri(update_uri_href) + + update_method = update_uri_template.get("method", "") + response = siren_client.request(update_method, update_uri, json=new_data).json() name = response.get("properties", {}).get("name") assert name == new_data.get("name") @@ -167,21 +176,24 @@ def test_update_item_from_uri_template( def test_update_item_from_update_uri( - siren_client: TestClient, find_uri_template: str, item: Item + siren_client: TestClient, find_uri_template: Mapping[str, str], item: Item ) -> None: - find_uri = item.parse_uri(find_uri_template) - before = siren_client.get(find_uri).json() + find_uri_href = find_uri_template.get("href", "") + find_uri = item.parse_uri(find_uri_href) + + find_method = find_uri_template.get("method", "") + before = siren_client.request(find_method, find_uri).json() new_data = {"name": f"updated_{uuid.uuid4().hex}"} update_action = get_siren_action(before, "update") - uddate_method = update_action.get("method") - assert uddate_method + update_method = update_action.get("method") + assert update_method update_uri = update_action.get("href") assert update_uri - response = siren_client.request(uddate_method, update_uri, json=new_data).json() + response = siren_client.request(update_method, update_uri, json=new_data).json() name = response.get("properties", {}).get("name") assert name == new_data.get("name") diff --git a/tests/integration/siren/test_siren_people.py b/tests/integration/siren/test_siren_people.py index 37b062f..c880d3c 100644 --- a/tests/integration/siren/test_siren_people.py +++ b/tests/integration/siren/test_siren_people.py @@ -1,186 +1,237 @@ -# import uuid -# from typing import Any +import uuid +from typing import Any, Mapping, Sequence -# import pytest -# from fastapi.testclient import TestClient +import pytest +from fastapi.testclient import TestClient -# from examples.hal import Person -# from fastapi_hypermodel import get_hal_link_href +from examples.siren import Person +from fastapi_hypermodel import get_siren_action, get_siren_link -# @pytest.fixture() -# def people_uri() -> str: -# return "/people" +@pytest.fixture() +def people_uri() -> str: + return "/people" -# @pytest.fixture() -# def find_uri_template(hal_client: TestClient, people_uri: str) -> str: -# find_uri = get_hal_link_href(hal_client.get(people_uri).json(), "find") -# assert find_uri -# return find_uri +@pytest.fixture() +def find_uri_template(siren_client: TestClient, people_uri: str) -> Mapping[str, str]: + reponse = siren_client.get(people_uri).json() + find_uri = get_siren_action(reponse, "find") + assert find_uri + return find_uri -# @pytest.fixture() -# def update_uri_template(hal_client: TestClient, people_uri: str) -> str: -# update_uri = get_hal_link_href(hal_client.get(people_uri).json(), "update") -# assert update_uri -# return update_uri +@pytest.fixture() +def update_uri_template(siren_client: TestClient, people_uri: str) -> Mapping[str, str]: + reponse = siren_client.get(people_uri).json() + update_uri = get_siren_action(reponse, "update") + assert update_uri + return update_uri -# def test_people_content_type(hal_client: TestClient, people_uri: str) -> None: -# response = hal_client.get(people_uri) -# content_type = response.headers.get("content-type") -# assert content_type == "application/hal+json" +def test_people_content_type(siren_client: TestClient, people_uri: str) -> None: + response = siren_client.get(people_uri) + content_type = response.headers.get("content-type") + assert content_type == "application/siren+json" -# def test_get_people(hal_client: TestClient, people_uri: str) -> None: -# response = hal_client.get(people_uri).json() +def test_get_people(siren_client: TestClient, people_uri: str) -> None: + response = siren_client.get(people_uri).json() -# self_uri = get_hal_link_href(response, "self") -# assert self_uri == people_uri + self_uri = get_siren_link(response, "self").get("href") + assert self_uri == people_uri -# find_uri = response.get("_links", {}).get("find", {}) -# assert find_uri.get("templated") -# assert people_uri in find_uri.get("href") + find_uri = get_siren_action(response, "find") + assert find_uri.get("templated") + assert people_uri in find_uri.get("href", "") -# def test_get_person( -# hal_client: TestClient, find_uri_template: str, person: Person, people_uri: str -# ) -> None: -# find_uri = person.parse_uri(find_uri_template) -# person_response = hal_client.get(find_uri).json() +def test_get_person( + siren_client: TestClient, + find_uri_template: Mapping[str, str], + person: Person, + people_uri: str, +) -> None: + find_uri_href = find_uri_template.get("href", "") + find_uri = person.parse_uri(find_uri_href) -# person_href = get_hal_link_href(person_response, "self") + find_method = find_uri_template.get("method", "") + person_response = siren_client.request(find_method, find_uri).json() -# assert people_uri in person_href -# assert person.id_ in person_href -# assert person_response.get("id_") == person.id_ + person_href = get_siren_link(person_response, "self").get("href", "") -# embedded = person_response.get("_embedded") -# assert embedded + assert people_uri in person_href -# items = embedded.get("sc:items") -# assert items + assert person.properties + person_id = person.properties.get("id_", "") + assert person_id in person_href + assert person_response.get("properties").get("id_") == person_id -# def test_update_person_from_uri_template( -# hal_client: TestClient, -# find_uri_template: str, -# update_uri_template: str, -# person: Person, -# ) -> None: -# find_uri = person.parse_uri(find_uri_template) -# before = hal_client.get(find_uri).json() + entities = person_response.get("entities") + assert entities + assert len(entities) == 2 -# new_data = {"name": f"updated_{uuid.uuid4().hex}"} -# update_uri = person.parse_uri(update_uri_template) -# response = hal_client.put(update_uri, json=new_data).json() -# assert response.get("name") == new_data.get("name") -# assert response.get("name") != before.get("name") +def test_update_person_from_uri_template( + siren_client: TestClient, + find_uri_template: Mapping[str, str], + update_uri_template: Mapping[str, str], + person: Person, +) -> None: + find_uri_href = find_uri_template.get("href", "") + find_uri = person.parse_uri(find_uri_href) -# before_uri = get_hal_link_href(before, "self") -# after_uri = get_hal_link_href(response, "self") + find_method = find_uri_template.get("method", "") + before = siren_client.request(find_method, find_uri).json() -# assert before_uri == after_uri + new_data = {"name": f"updated_{uuid.uuid4().hex}"} + update_uri_href = update_uri_template.get("href", "") + update_uri = person.parse_uri(update_uri_href) -# def test_update_person_from_update_uri( -# hal_client: TestClient, find_uri_template: str, person: Person -# ) -> None: -# find_uri = person.parse_uri(find_uri_template) -# before = hal_client.get(find_uri).json() + update_method = update_uri_template.get("method", "") + response = siren_client.request(update_method, update_uri, json=new_data).json() -# new_data = {"name": f"updated_{uuid.uuid4().hex}"} + assert response.get("properties").get("name") == new_data.get("name") + assert response.get("properties").get("name") != before.get("name") -# update_uri = get_hal_link_href(before, "update") -# response = hal_client.put(update_uri, json=new_data).json() + before_uri = get_siren_link(before, "self").get("href") + after_uri = get_siren_link(response, "self").get("href") -# assert response.get("name") == new_data.get("name") -# assert response.get("name") != before.get("name") + assert before_uri == after_uri -# before_uri = get_hal_link_href(before, "self") -# after_uri = get_hal_link_href(response, "self") -# assert before_uri == after_uri +def test_update_person_from_update_uri( + siren_client: TestClient, find_uri_template: Mapping[str, str], person: Person +) -> None: + find_uri_href = find_uri_template.get("href", "") + find_uri = person.parse_uri(find_uri_href) + find_method = find_uri_template.get("method", "") + before = siren_client.request(find_method, find_uri).json() -# def test_get_person_items( -# hal_client: TestClient, find_uri_template: str, person: Person -# ) -> None: -# find_uri = person.parse_uri(find_uri_template) -# person_response = hal_client.get(find_uri).json() + new_data = {"name": f"updated_{uuid.uuid4().hex}"} -# person_items = person_response.get("_embedded").get("sc:items") + update_action = get_siren_action(before, "update") + update_href = update_action.get("href", "") + update_method = update_action.get("method", "") + response = siren_client.request(update_method, update_href, json=new_data).json() -# assert person_items -# assert isinstance(person_items, list) + assert response.get("properties").get("name") == new_data.get("name") + assert response.get("properties").get("name") != before.get("name") -# first_item, *_ = person_items -# first_item_uri = get_hal_link_href(first_item, "self") -# first_item_response = hal_client.get(first_item_uri).json() + before_uri = get_siren_link(before, "self").get("href") + after_uri = get_siren_link(response, "self").get("href") -# assert first_item == first_item_response + assert before_uri == after_uri -# @pytest.fixture() -# def existing_item() -> Any: -# return {"id_": "item04"} +def test_get_person_items( + siren_client: TestClient, find_uri_template: Mapping[str, str], person: Person +) -> None: + find_uri_href = find_uri_template.get("href", "") + find_uri = person.parse_uri(find_uri_href) + find_method = find_uri_template.get("method", "") + person_response = siren_client.request(find_method, find_uri).json() -# @pytest.fixture() -# def non_existing_item() -> Any: -# return {"id_": "item05"} + person_items: Sequence[Mapping[str, str]] = person_response.get("entities") + assert person_items + assert isinstance(person_items, list) -# def test_add_item_to_unlocked_person( -# hal_client: TestClient, -# find_uri_template: str, -# unlocked_person: Person, -# existing_item: Any, -# ) -> None: -# find_uri = unlocked_person.parse_uri(find_uri_template) -# before = hal_client.get(find_uri).json() -# before_items = before.get("_embedded", {}).get("sc:items", []) -# add_item_uri = get_hal_link_href(before, "add_item") + first_item, *_ = person_items + first_item_uri = get_siren_link(first_item, "self").get("href", "") + first_item_response = siren_client.get(first_item_uri).json() + first_item_response.update({"rel": "items"}) -# assert add_item_uri + assert first_item == first_item_response -# after = hal_client.put(add_item_uri, json=existing_item).json() -# after_items = after.get("_embedded", {}).get("sc:items", []) -# assert after_items -# lenght_before = len(before_items) -# lenght_after = len(after_items) -# assert lenght_before + 1 == lenght_after +@pytest.fixture() +def existing_item() -> Any: + return {"id_": "item04"} -# assert after_items[-1].get("id_") == existing_item.get("id_") +@pytest.fixture() +def non_existing_item() -> Any: + return {"id_": "item05"} -# def test_add_item_to_unlocked_person_nonexisting_item( -# hal_client: TestClient, -# find_uri_template: str, -# unlocked_person: Person, -# non_existing_item: Any, -# ) -> None: -# find_uri = unlocked_person.parse_uri(find_uri_template) -# before = hal_client.get(find_uri).json() -# add_item_uri = get_hal_link_href(before, "add_item") -# assert add_item_uri +def test_add_item_to_unlocked_person( + siren_client: TestClient, + find_uri_template: Mapping[str, str], + unlocked_person: Person, + existing_item: Any, +) -> None: + find_uri_href = find_uri_template.get("href", "") + find_uri = unlocked_person.parse_uri(find_uri_href) -# response = hal_client.put(add_item_uri, json=non_existing_item) -# assert response.status_code == 404 -# assert response.json() == {"detail": "No item found with id item05"} + find_method = find_uri_template.get("method", "") + before = siren_client.request(find_method, find_uri).json() + before_items = before.get("entities", {}) + add_item_action = get_siren_action(before, "add_item") -# def test_add_item_to_locked_person( -# hal_client: TestClient, -# find_uri_template: str, -# locked_person: Person, -# ) -> None: -# find_uri = locked_person.parse_uri(find_uri_template) -# before = hal_client.get(find_uri).json() -# add_item_uri = get_hal_link_href(before, "add_item") + assert add_item_action -# assert not add_item_uri + add_item_href = add_item_action.get("href", "") + add_item_method = add_item_action.get("method", "") + + after = siren_client.request( + add_item_method, add_item_href, json=existing_item + ).json() + after_items = after.get("entities", {}) + assert after_items + + lenght_before = len(before_items) + lenght_after = len(after_items) + assert lenght_before + 1 == lenght_after + + assert after_items[-1].get("properties").get("id_") == existing_item.get("id_") + + +def test_add_item_to_unlocked_person_nonexisting_item( + siren_client: TestClient, + find_uri_template: Mapping[str, str], + unlocked_person: Person, + non_existing_item: Any, +) -> None: + find_uri_href = find_uri_template.get("href", "") + find_uri = unlocked_person.parse_uri(find_uri_href) + + find_method = find_uri_template.get("method", "") + + before = siren_client.request(find_method, find_uri).json() + + add_item_action = get_siren_action(before, "add_item") + + assert add_item_action + + add_item_href = add_item_action.get("href", "") + add_item_method = add_item_action.get("method", "") + + response = siren_client.request( + add_item_method, add_item_href, json=non_existing_item + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "No item found with id item05"} + + +def test_add_item_to_locked_person( + siren_client: TestClient, + find_uri_template: Mapping[str, str], + locked_person: Person, +) -> None: + find_uri_href = find_uri_template.get("href", "") + find_uri = locked_person.parse_uri(find_uri_href) + + find_method = find_uri_template.get("method", "") + + before = siren_client.request(find_method, find_uri).json() + + add_item_action = get_siren_link(before, "add_item").get("href") + + assert not add_item_action From 11d76e293920e9478bd47909eba4304d16c88218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Mon, 15 Jan 2024 22:29:55 -0300 Subject: [PATCH 10/66] Fix bug with conditioned actions and links --- examples/siren/app.py | 2 +- fastapi_hypermodel/siren.py | 21 +++++++++++++++++--- fastapi_hypermodel/utils.py | 6 +++--- tests/integration/siren/test_siren_people.py | 5 +++++ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/examples/siren/app.py b/examples/siren/app.py index 9fdcc5d..94a865d 100644 --- a/examples/siren/app.py +++ b/examples/siren/app.py @@ -77,7 +77,7 @@ class Person(SirenHyperModel): class PersonCollection(SirenHyperModel): - # people: Sequence[Person] + people: Sequence[Person] links: Sequence[SirenLinkFor] = (SirenLinkFor("read_people", rel=["self"]),) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 486b62f..d0ab5a0 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -6,6 +6,7 @@ Any, Callable, Dict, + List, Literal, Mapping, Optional, @@ -376,7 +377,14 @@ def add_links(self: Self) -> Self: self.properties = self.properties or {} - self.links = [link(self._app, self.properties) for link in links] + validated_links: List[SirenLinkType] = [] + for link_factory in links: + link = link_factory(self._app, self.properties) + if not link: + continue + validated_links.append(link) + + self.links = validated_links return self @@ -392,7 +400,14 @@ def add_actions(self: Self) -> Self: self.properties = self.properties or {} - self.actions = [action(self._app, self.properties) for action in actions] + validated_actions: List[SirenActionType] = [] + for action_factory in actions: + action = action_factory(self._app, self.properties) + if not action: + continue + validated_actions.append(action) + + self.actions = validated_actions return self @@ -421,7 +436,7 @@ def add_hypermodels_to_entities(self: Self) -> Self: @model_serializer def serialize(self: Self) -> Mapping[str, Any]: - return {self.model_fields[k].alias or k: v for k, v in self if v} + return {self.model_fields[k].alias or k: v for k, v in self} @staticmethod def as_embedded(field: SirenHyperModel, rel: str) -> SirenEmbeddedType: diff --git a/fastapi_hypermodel/utils.py b/fastapi_hypermodel/utils.py index dc6fc9b..8bc7b7c 100644 --- a/fastapi_hypermodel/utils.py +++ b/fastapi_hypermodel/utils.py @@ -117,9 +117,9 @@ def get_siren_link(response: Any, link_name: str) -> Mapping[str, Any]: return next((link for link in links if link_name in link.get("rel")), {}) -def get_siren_action(response: Any, link_name: str) -> Mapping[str, Any]: - links = response.get("actions", []) - return next((link for link in links if link_name in link.get("name")), {}) +def get_siren_action(response: Any, action_name: str) -> Mapping[str, Any]: + actions = response.get("actions", []) + return next((action for action in actions if action_name in action.get("name")), {}) def get_route_from_app(app: Starlette, endpoint_function: str) -> Route: diff --git a/tests/integration/siren/test_siren_people.py b/tests/integration/siren/test_siren_people.py index c880d3c..8a25614 100644 --- a/tests/integration/siren/test_siren_people.py +++ b/tests/integration/siren/test_siren_people.py @@ -38,6 +38,10 @@ def test_people_content_type(siren_client: TestClient, people_uri: str) -> None: def test_get_people(siren_client: TestClient, people_uri: str) -> None: response = siren_client.get(people_uri).json() + people = response.get("entities", []) + assert len(people) == 2 + assert all(person.get("rel") == "people" for person in people) + self_uri = get_siren_link(response, "self").get("href") assert self_uri == people_uri @@ -140,6 +144,7 @@ def test_get_person_items( assert person_items assert isinstance(person_items, list) + assert all(item.get("rel") == "items" for item in person_items) first_item, *_ = person_items first_item_uri = get_siren_link(first_item, "self").get("href", "") From 14f758e71fb37264cba1d094a00ade038e32eaee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Mon, 15 Jan 2024 22:49:56 -0300 Subject: [PATCH 11/66] Simplify integration tests --- tests/integration/siren/test_siren_people.py | 63 ++++++++------------ 1 file changed, 25 insertions(+), 38 deletions(-) diff --git a/tests/integration/siren/test_siren_people.py b/tests/integration/siren/test_siren_people.py index 8a25614..50bfded 100644 --- a/tests/integration/siren/test_siren_people.py +++ b/tests/integration/siren/test_siren_people.py @@ -28,6 +28,18 @@ def update_uri_template(siren_client: TestClient, people_uri: str) -> Mapping[st assert update_uri return update_uri +@pytest.fixture() +def person_response( + siren_client: TestClient, + find_uri_template: Mapping[str, str], + person: Person +) -> Mapping[str, Any]: + find_uri_href = find_uri_template.get("href", "") + find_uri = person.parse_uri(find_uri_href) + + find_method = find_uri_template.get("method", "") + return siren_client.request(find_method, find_uri).json() + def test_people_content_type(siren_client: TestClient, people_uri: str) -> None: response = siren_client.get(people_uri) @@ -51,17 +63,10 @@ def test_get_people(siren_client: TestClient, people_uri: str) -> None: def test_get_person( - siren_client: TestClient, - find_uri_template: Mapping[str, str], + person_response: Mapping[str, Any], person: Person, people_uri: str, ) -> None: - find_uri_href = find_uri_template.get("href", "") - find_uri = person.parse_uri(find_uri_href) - - find_method = find_uri_template.get("method", "") - person_response = siren_client.request(find_method, find_uri).json() - person_href = get_siren_link(person_response, "self").get("href", "") assert people_uri in person_href @@ -70,7 +75,7 @@ def test_get_person( person_id = person.properties.get("id_", "") assert person_id in person_href - assert person_response.get("properties").get("id_") == person_id + assert person_response.get("properties", {}).get("id_") == person_id entities = person_response.get("entities") assert entities @@ -79,16 +84,10 @@ def test_get_person( def test_update_person_from_uri_template( siren_client: TestClient, - find_uri_template: Mapping[str, str], + person_response: Mapping[str, Any], update_uri_template: Mapping[str, str], person: Person, ) -> None: - find_uri_href = find_uri_template.get("href", "") - find_uri = person.parse_uri(find_uri_href) - - find_method = find_uri_template.get("method", "") - before = siren_client.request(find_method, find_uri).json() - new_data = {"name": f"updated_{uuid.uuid4().hex}"} update_uri_href = update_uri_template.get("href", "") @@ -98,49 +97,37 @@ def test_update_person_from_uri_template( response = siren_client.request(update_method, update_uri, json=new_data).json() assert response.get("properties").get("name") == new_data.get("name") - assert response.get("properties").get("name") != before.get("name") + assert response.get("properties").get("name") != person_response.get("name") - before_uri = get_siren_link(before, "self").get("href") + person_response_uri = get_siren_link(person_response, "self").get("href") after_uri = get_siren_link(response, "self").get("href") - assert before_uri == after_uri + assert person_response_uri == after_uri def test_update_person_from_update_uri( - siren_client: TestClient, find_uri_template: Mapping[str, str], person: Person + siren_client: TestClient, person_response: Mapping[str, Any] ) -> None: - find_uri_href = find_uri_template.get("href", "") - find_uri = person.parse_uri(find_uri_href) - - find_method = find_uri_template.get("method", "") - before = siren_client.request(find_method, find_uri).json() - new_data = {"name": f"updated_{uuid.uuid4().hex}"} - update_action = get_siren_action(before, "update") + update_action = get_siren_action(person_response, "update") update_href = update_action.get("href", "") update_method = update_action.get("method", "") response = siren_client.request(update_method, update_href, json=new_data).json() assert response.get("properties").get("name") == new_data.get("name") - assert response.get("properties").get("name") != before.get("name") + assert response.get("properties").get("name") != person_response.get("name") - before_uri = get_siren_link(before, "self").get("href") + person_response_uri = get_siren_link(person_response, "self").get("href") after_uri = get_siren_link(response, "self").get("href") - assert before_uri == after_uri + assert person_response_uri == after_uri def test_get_person_items( - siren_client: TestClient, find_uri_template: Mapping[str, str], person: Person + siren_client: TestClient, person_response: Mapping[str, Any] ) -> None: - find_uri_href = find_uri_template.get("href", "") - find_uri = person.parse_uri(find_uri_href) - - find_method = find_uri_template.get("method", "") - person_response = siren_client.request(find_method, find_uri).json() - - person_items: Sequence[Mapping[str, str]] = person_response.get("entities") + person_items: Sequence[Mapping[str, str]] = person_response.get("entities", []) assert person_items assert isinstance(person_items, list) From a3dfde579a4222e8d48a108ddf24b095a89e4700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Tue, 16 Jan 2024 20:56:59 -0300 Subject: [PATCH 12/66] Enable optional population of fields --- examples/siren/app.py | 3 ++- fastapi_hypermodel/siren.py | 6 ++++++ tests/integration/siren/test_siren_people.py | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/examples/siren/app.py b/examples/siren/app.py index 94a865d..f55d12e 100644 --- a/examples/siren/app.py +++ b/examples/siren/app.py @@ -38,7 +38,7 @@ class ItemUpdate(BaseModel): price: Optional[float] = None -class ItemCreate(ItemUpdate): +class ItemCreate(BaseModel): id_: str @@ -72,6 +72,7 @@ class Person(SirenHyperModel): description="Add an item to this person and the items list", condition=lambda values: not values["is_locked"], name="add_item", + populate_fields=False, ), ) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index d0ab5a0..212930e 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -202,6 +202,7 @@ class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): _param_values: Mapping[str, str] = PrivateAttr() _templated: bool = PrivateAttr() _condition: Callable[[Mapping[str, Any]], bool] | None = PrivateAttr() + _populate_fields: bool = PrivateAttr() # For details on the folllowing fields, check https://github.com/kevinswiber/siren _class: Sequence[str] | None = PrivateAttr() @@ -217,6 +218,7 @@ def __init__( param_values: Mapping[str, str] | None = None, templated: bool = False, condition: Callable[[Mapping[str, Any]], bool] | None = None, + populate_fields: bool = True, title: str | None = None, type_: str | None = "application/x-www-form-urlencoded", class_: Sequence[str] | None = None, @@ -232,6 +234,7 @@ def __init__( self._param_values = param_values or {} self._templated = templated self._condition = condition + self._populate_fields = populate_fields self._title = title self._type = type_ self._fields = fields or [] @@ -251,6 +254,9 @@ def _get_uri_path( def _prepopulate_fields( self: Self, fields: Sequence[SirenFieldType], values: Mapping[str, Any] ) -> list[SirenFieldType]: + if not self._populate_fields: + return list(fields) + for field in fields: field.value = values.get(field.name) or field.value return list(fields) diff --git a/tests/integration/siren/test_siren_people.py b/tests/integration/siren/test_siren_people.py index 50bfded..c9ca7ac 100644 --- a/tests/integration/siren/test_siren_people.py +++ b/tests/integration/siren/test_siren_people.py @@ -170,6 +170,10 @@ def test_add_item_to_unlocked_person( add_item_href = add_item_action.get("href", "") add_item_method = add_item_action.get("method", "") + required_fields = add_item_action.get("fields", {}) + + for required_field in required_fields: + assert existing_item.get(required_field.get("name")) after = siren_client.request( add_item_method, add_item_href, json=existing_item From 9ed857fb881d116713a47bf1f99584e75f23808c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Tue, 16 Jan 2024 21:17:12 -0300 Subject: [PATCH 13/66] Use Model Objects instead of dicts --- fastapi_hypermodel/__init__.py | 6 +- fastapi_hypermodel/siren.py | 16 +++ fastapi_hypermodel/utils.py | 10 -- tests/integration/siren/test_siren_items.py | 69 ++++++----- tests/integration/siren/test_siren_people.py | 119 ++++++++++--------- 5 files changed, 116 insertions(+), 104 deletions(-) diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index aca4d4a..bb5102b 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -7,10 +7,13 @@ from .linkset import LinkSet, LinkSetType from .siren import ( SirenActionFor, + SirenActionType, SirenHyperModel, SirenLinkFor, SirenLinkType, SirenResponse, + get_siren_action, + get_siren_link, ) from .url_for import UrlFor from .url_type import URL_TYPE_SCHEMA, UrlType @@ -19,8 +22,6 @@ extract_value_by_name, get_hal_link_href, get_route_from_app, - get_siren_action, - get_siren_link, resolve_param_values, ) @@ -33,6 +34,7 @@ "HALForType", "HALResponse", "HalHyperModel", + "SirenActionType", "SirenActionFor", "SirenLinkFor", "SirenLinkType", diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 212930e..327cd41 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -470,3 +470,19 @@ def _validate(self: Self, content: Any) -> None: def render(self: Self, content: Any) -> bytes: self._validate(content) return super().render(content) + + +def get_siren_link(response: Any, link_name: str) -> SirenLinkType | None: + links = response.get("links", []) + link = next((link for link in links if link_name in link.get("rel")), {}) + if not link: + return None + return SirenLinkType.model_validate(link) + + +def get_siren_action(response: Any, action_name: str) -> SirenActionType | None: + actions = response.get("actions", []) + action = next((action for action in actions if action_name in action.get("name")), {}) + if not action: + return None + return SirenActionType.model_validate(action) \ No newline at end of file diff --git a/fastapi_hypermodel/utils.py b/fastapi_hypermodel/utils.py index 8bc7b7c..d15ba7f 100644 --- a/fastapi_hypermodel/utils.py +++ b/fastapi_hypermodel/utils.py @@ -112,16 +112,6 @@ def get_hal_link_href(response: Any, link_name: str) -> Union[str, Any]: return response.get("_links", {}).get(link_name, {}).get("href", "") -def get_siren_link(response: Any, link_name: str) -> Mapping[str, Any]: - links = response.get("links", []) - return next((link for link in links if link_name in link.get("rel")), {}) - - -def get_siren_action(response: Any, action_name: str) -> Mapping[str, Any]: - actions = response.get("actions", []) - return next((action for action in actions if action_name in action.get("name")), {}) - - def get_route_from_app(app: Starlette, endpoint_function: str) -> Route: for route in app.routes: if isinstance(route, Route) and route.name == endpoint_function: diff --git a/tests/integration/siren/test_siren_items.py b/tests/integration/siren/test_siren_items.py index b0e4722..fd1d3ee 100644 --- a/tests/integration/siren/test_siren_items.py +++ b/tests/integration/siren/test_siren_items.py @@ -1,11 +1,11 @@ import uuid -from typing import Any, List, Mapping +from typing import Any, List import pytest from fastapi.testclient import TestClient from examples.siren import Item -from fastapi_hypermodel import get_siren_action, get_siren_link +from fastapi_hypermodel import get_siren_action, get_siren_link, SirenActionType @pytest.fixture() @@ -14,7 +14,7 @@ def item_uri() -> str: @pytest.fixture() -def find_uri_template(siren_client: TestClient, item_uri: str) -> Mapping[str, str]: +def find_uri_template(siren_client: TestClient, item_uri: str) -> SirenActionType: reponse = siren_client.get(item_uri).json() find_uri = get_siren_action(reponse, "find") assert find_uri @@ -22,7 +22,7 @@ def find_uri_template(siren_client: TestClient, item_uri: str) -> Mapping[str, s @pytest.fixture() -def update_uri_template(siren_client: TestClient, item_uri: str) -> Mapping[str, str]: +def update_uri_template(siren_client: TestClient, item_uri: str) -> SirenActionType: reponse = siren_client.get(item_uri).json() update_uri = get_siren_action(reponse, "update") assert update_uri @@ -111,7 +111,8 @@ def test_get_items(siren_client: TestClient, item_uri: str) -> None: response = siren_client.get(item_uri).json() self_link = get_siren_link(response, "self") - assert self_link.get("href", "") == item_uri + assert self_link + assert self_link.href == item_uri links = response.get("actions", []) find_uri = next(link for link in links if "find" in link.get("name")) @@ -125,45 +126,44 @@ def test_get_items(siren_client: TestClient, item_uri: str) -> None: def test_get_item( siren_client: TestClient, - find_uri_template: Mapping[str, str], + find_uri_template: SirenActionType, item_uri: str, item: Item, ) -> None: - find_uri_href = find_uri_template.get("href", "") - find_uri = item.parse_uri(find_uri_href) + find_uri = item.parse_uri(find_uri_template.href) - find_method = find_uri_template.get("method", "") - item_response = siren_client.request(find_method, find_uri).json() + assert find_uri_template.method - item_href = get_siren_link(item_response, "self").get("href", "") + item_response = siren_client.request(find_uri_template.method, find_uri).json() + item_href = get_siren_link(item_response, "self") + assert item_href assert item.properties item_id = item.properties.get("id_", "") - assert item_uri in item_href - assert item_id in item_href + assert item_uri in item_href.href + assert item_id in item_href.href assert item_response.get("properties").get("id_") == item_id def test_update_item_from_uri_template( siren_client: TestClient, - find_uri_template: Mapping[str, str], - update_uri_template: Mapping[str, str], + find_uri_template: SirenActionType, + update_uri_template: SirenActionType, item: Item, ) -> None: - find_uri_href = find_uri_template.get("href", "") - find_uri = item.parse_uri(find_uri_href) + find_uri = item.parse_uri(find_uri_template.href) - find_method = find_uri_template.get("method", "") - before = siren_client.request(find_method, find_uri).json() + assert find_uri_template.method + + before = siren_client.request(find_uri_template.method, find_uri).json() new_data = {"name": f"updated_{uuid.uuid4().hex}"} - update_uri_href = update_uri_template.get("href", "") - update_uri = item.parse_uri(update_uri_href) + update_uri = item.parse_uri(update_uri_template.href) - update_method = update_uri_template.get("method", "") - response = siren_client.request(update_method, update_uri, json=new_data).json() + assert update_uri_template.method + response = siren_client.request(update_uri_template.method, update_uri, json=new_data).json() name = response.get("properties", {}).get("name") assert name == new_data.get("name") @@ -172,28 +172,29 @@ def test_update_item_from_uri_template( before_uri = get_siren_link(before, "self") after_uri = get_siren_link(response, "self") + assert before_uri + assert after_uri assert before_uri == after_uri def test_update_item_from_update_uri( - siren_client: TestClient, find_uri_template: Mapping[str, str], item: Item + siren_client: TestClient, find_uri_template: SirenActionType, item: Item ) -> None: - find_uri_href = find_uri_template.get("href", "") - find_uri = item.parse_uri(find_uri_href) + find_uri = item.parse_uri(find_uri_template.href) + + assert find_uri_template.method - find_method = find_uri_template.get("method", "") - before = siren_client.request(find_method, find_uri).json() + before = siren_client.request(find_uri_template.method, find_uri).json() new_data = {"name": f"updated_{uuid.uuid4().hex}"} update_action = get_siren_action(before, "update") - update_method = update_action.get("method") - assert update_method - update_uri = update_action.get("href") - assert update_uri + assert update_action + assert update_action.method + assert update_action.href - response = siren_client.request(update_method, update_uri, json=new_data).json() + response = siren_client.request(update_action.method, update_action.href, json=new_data).json() name = response.get("properties", {}).get("name") assert name == new_data.get("name") @@ -202,4 +203,6 @@ def test_update_item_from_update_uri( before_uri = get_siren_link(before, "self") after_uri = get_siren_link(response, "self") + assert before_uri + assert after_uri assert before_uri == after_uri diff --git a/tests/integration/siren/test_siren_people.py b/tests/integration/siren/test_siren_people.py index c9ca7ac..f4f91c4 100644 --- a/tests/integration/siren/test_siren_people.py +++ b/tests/integration/siren/test_siren_people.py @@ -5,7 +5,7 @@ from fastapi.testclient import TestClient from examples.siren import Person -from fastapi_hypermodel import get_siren_action, get_siren_link +from fastapi_hypermodel import get_siren_action, get_siren_link, SirenActionType @pytest.fixture() @@ -14,7 +14,7 @@ def people_uri() -> str: @pytest.fixture() -def find_uri_template(siren_client: TestClient, people_uri: str) -> Mapping[str, str]: +def find_uri_template(siren_client: TestClient, people_uri: str) -> SirenActionType: reponse = siren_client.get(people_uri).json() find_uri = get_siren_action(reponse, "find") assert find_uri @@ -22,7 +22,7 @@ def find_uri_template(siren_client: TestClient, people_uri: str) -> Mapping[str, @pytest.fixture() -def update_uri_template(siren_client: TestClient, people_uri: str) -> Mapping[str, str]: +def update_uri_template(siren_client: TestClient, people_uri: str) -> SirenActionType: reponse = siren_client.get(people_uri).json() update_uri = get_siren_action(reponse, "update") assert update_uri @@ -31,14 +31,14 @@ def update_uri_template(siren_client: TestClient, people_uri: str) -> Mapping[st @pytest.fixture() def person_response( siren_client: TestClient, - find_uri_template: Mapping[str, str], + find_uri_template: SirenActionType, person: Person ) -> Mapping[str, Any]: - find_uri_href = find_uri_template.get("href", "") - find_uri = person.parse_uri(find_uri_href) + find_uri = person.parse_uri(find_uri_template.href) - find_method = find_uri_template.get("method", "") - return siren_client.request(find_method, find_uri).json() + assert find_uri_template.method + + return siren_client.request(find_uri_template.method, find_uri).json() def test_people_content_type(siren_client: TestClient, people_uri: str) -> None: @@ -54,12 +54,14 @@ def test_get_people(siren_client: TestClient, people_uri: str) -> None: assert len(people) == 2 assert all(person.get("rel") == "people" for person in people) - self_uri = get_siren_link(response, "self").get("href") - assert self_uri == people_uri + self_uri = get_siren_link(response, "self") + assert self_uri + assert self_uri.href == people_uri find_uri = get_siren_action(response, "find") - assert find_uri.get("templated") - assert people_uri in find_uri.get("href", "") + assert find_uri + assert find_uri.templated + assert people_uri in find_uri.href def test_get_person( @@ -67,14 +69,16 @@ def test_get_person( person: Person, people_uri: str, ) -> None: - person_href = get_siren_link(person_response, "self").get("href", "") + person_self_link = get_siren_link(person_response, "self") + + assert person_self_link - assert people_uri in person_href + assert people_uri in person_self_link.href assert person.properties person_id = person.properties.get("id_", "") - assert person_id in person_href + assert person_id in person_self_link.href assert person_response.get("properties", {}).get("id_") == person_id entities = person_response.get("entities") @@ -85,24 +89,25 @@ def test_get_person( def test_update_person_from_uri_template( siren_client: TestClient, person_response: Mapping[str, Any], - update_uri_template: Mapping[str, str], + update_uri_template: SirenActionType, person: Person, ) -> None: new_data = {"name": f"updated_{uuid.uuid4().hex}"} - update_uri_href = update_uri_template.get("href", "") - update_uri = person.parse_uri(update_uri_href) + update_uri = person.parse_uri(update_uri_template.href) - update_method = update_uri_template.get("method", "") - response = siren_client.request(update_method, update_uri, json=new_data).json() + assert update_uri_template.method + response = siren_client.request(update_uri_template.method, update_uri, json=new_data).json() assert response.get("properties").get("name") == new_data.get("name") assert response.get("properties").get("name") != person_response.get("name") - person_response_uri = get_siren_link(person_response, "self").get("href") - after_uri = get_siren_link(response, "self").get("href") - - assert person_response_uri == after_uri + person_response_uri = get_siren_link(person_response, "self") + after_uri = get_siren_link(response, "self") + + assert person_response_uri + assert after_uri + assert person_response_uri.href == after_uri.href def test_update_person_from_update_uri( @@ -111,17 +116,19 @@ def test_update_person_from_update_uri( new_data = {"name": f"updated_{uuid.uuid4().hex}"} update_action = get_siren_action(person_response, "update") - update_href = update_action.get("href", "") - update_method = update_action.get("method", "") - response = siren_client.request(update_method, update_href, json=new_data).json() + assert update_action + assert update_action.method + response = siren_client.request(update_action.method, update_action.href, json=new_data).json() assert response.get("properties").get("name") == new_data.get("name") assert response.get("properties").get("name") != person_response.get("name") - person_response_uri = get_siren_link(person_response, "self").get("href") - after_uri = get_siren_link(response, "self").get("href") + person_response_uri = get_siren_link(person_response, "self") + after_uri = get_siren_link(response, "self") - assert person_response_uri == after_uri + assert person_response_uri + assert after_uri + assert person_response_uri.href == after_uri.href def test_get_person_items( @@ -134,8 +141,9 @@ def test_get_person_items( assert all(item.get("rel") == "items" for item in person_items) first_item, *_ = person_items - first_item_uri = get_siren_link(first_item, "self").get("href", "") - first_item_response = siren_client.get(first_item_uri).json() + first_item_uri = get_siren_link(first_item, "self") + assert first_item_uri + first_item_response = siren_client.get(first_item_uri.href).json() first_item_response.update({"rel": "items"}) assert first_item == first_item_response @@ -153,30 +161,27 @@ def non_existing_item() -> Any: def test_add_item_to_unlocked_person( siren_client: TestClient, - find_uri_template: Mapping[str, str], + find_uri_template: SirenActionType, unlocked_person: Person, existing_item: Any, ) -> None: - find_uri_href = find_uri_template.get("href", "") - find_uri = unlocked_person.parse_uri(find_uri_href) + find_uri = unlocked_person.parse_uri(find_uri_template.href) - find_method = find_uri_template.get("method", "") - before = siren_client.request(find_method, find_uri).json() + assert find_uri_template.method + before = siren_client.request(find_uri_template.method, find_uri).json() before_items = before.get("entities", {}) add_item_action = get_siren_action(before, "add_item") assert add_item_action + assert add_item_action.method + assert add_item_action.fields - add_item_href = add_item_action.get("href", "") - add_item_method = add_item_action.get("method", "") - required_fields = add_item_action.get("fields", {}) - - for required_field in required_fields: - assert existing_item.get(required_field.get("name")) + for required_field in add_item_action.fields: + assert existing_item.get(required_field.name) after = siren_client.request( - add_item_method, add_item_href, json=existing_item + add_item_action.method, add_item_action.href, json=existing_item ).json() after_items = after.get("entities", {}) assert after_items @@ -190,26 +195,23 @@ def test_add_item_to_unlocked_person( def test_add_item_to_unlocked_person_nonexisting_item( siren_client: TestClient, - find_uri_template: Mapping[str, str], + find_uri_template: SirenActionType, unlocked_person: Person, non_existing_item: Any, ) -> None: - find_uri_href = find_uri_template.get("href", "") - find_uri = unlocked_person.parse_uri(find_uri_href) + find_uri = unlocked_person.parse_uri(find_uri_template.href) - find_method = find_uri_template.get("method", "") + assert find_uri_template.method - before = siren_client.request(find_method, find_uri).json() + before = siren_client.request(find_uri_template.method, find_uri).json() add_item_action = get_siren_action(before, "add_item") assert add_item_action - - add_item_href = add_item_action.get("href", "") - add_item_method = add_item_action.get("method", "") + assert add_item_action.method response = siren_client.request( - add_item_method, add_item_href, json=non_existing_item + add_item_action.method, add_item_action.href, json=non_existing_item ) assert response.status_code == 404 @@ -218,16 +220,15 @@ def test_add_item_to_unlocked_person_nonexisting_item( def test_add_item_to_locked_person( siren_client: TestClient, - find_uri_template: Mapping[str, str], + find_uri_template: SirenActionType, locked_person: Person, ) -> None: - find_uri_href = find_uri_template.get("href", "") - find_uri = locked_person.parse_uri(find_uri_href) + find_uri = locked_person.parse_uri(find_uri_template.href) - find_method = find_uri_template.get("method", "") + assert find_uri_template.method - before = siren_client.request(find_method, find_uri).json() + before = siren_client.request(find_uri_template.method, find_uri).json() - add_item_action = get_siren_link(before, "add_item").get("href") + add_item_action = get_siren_link(before, "add_item") assert not add_item_action From 009a33362d6bc73fa74e3f7986ae6c769c1754a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Tue, 16 Jan 2024 22:39:32 -0300 Subject: [PATCH 14/66] Refactor Actions and Link factory --- fastapi_hypermodel/siren.py | 60 ++++---- tests/integration/siren/test_siren_items.py | 8 -- tests/integration/siren/test_siren_people.py | 136 ++++++++----------- 3 files changed, 88 insertions(+), 116 deletions(-) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 327cd41..be1b303 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -11,6 +11,7 @@ Mapping, Optional, Sequence, + TypeVar, cast, ) @@ -181,12 +182,12 @@ def parse_type(python_type: type[Any] | None) -> FieldType: class SirenActionType(SirenBase): - name: str | None = Field(default="") - method: str | None = Field(default=None) + name: str = Field(default="") + method: str = Field(default="GET") href: UrlType = Field(default=UrlType()) - type_: str | None = Field(default="application/x-www-form-urlencoded", alias="type") + type_: str | None = Field(default=None, alias="type") fields: Sequence[SirenFieldType] | None = Field(default=None) - templated: bool | None = Field(default=False) + templated: bool = Field(default=False) @field_validator("name", "href") @classmethod @@ -220,7 +221,7 @@ def __init__( condition: Callable[[Mapping[str, Any]], bool] | None = None, populate_fields: bool = True, title: str | None = None, - type_: str | None = "application/x-www-form-urlencoded", + type_: str = "application/x-www-form-urlencoded", class_: Sequence[str] | None = None, fields: Sequence[SirenFieldType] | None = None, method: str | None = None, @@ -302,6 +303,9 @@ def __call__( if not self._fields: self._fields = self._compute_fields(route, values) + if not self._type and self._fields: + self._type = "application/x-www-form-urlencoded" + # Using model_validate to avoid conflicts with class and type return SirenActionType.model_validate({ "href": uri_path, @@ -326,6 +330,9 @@ class SirenEmbeddedType(SirenEntityType): rel: str = Field() +T = TypeVar("T", bound=Callable[..., Any]) + + class SirenHyperModel(HyperModel): properties: dict[str, Any] | None = None entities: Sequence[SirenEmbeddedType | SirenLinkType] | None = None @@ -380,17 +387,8 @@ def add_links(self: Self) -> Self: continue links = cast(Sequence[SirenLinkFor], value) - - self.properties = self.properties or {} - - validated_links: List[SirenLinkType] = [] - for link_factory in links: - link = link_factory(self._app, self.properties) - if not link: - continue - validated_links.append(link) - - self.links = validated_links + properties = self.properties or {} + self.links = self._validate_factory(links, properties) return self @@ -402,21 +400,23 @@ def add_actions(self: Self) -> Self: if key != "actions" or not value: continue + properties = self.properties or {} actions = cast(Sequence[SirenActionFor], value) - - self.properties = self.properties or {} - - validated_actions: List[SirenActionType] = [] - for action_factory in actions: - action = action_factory(self._app, self.properties) - if not action: - continue - validated_actions.append(action) - - self.actions = validated_actions + self.actions = self._validate_factory(actions, properties) return self + def _validate_factory( + self: Self, elements: Sequence[T], properties: Mapping[str, str] + ) -> List[T]: + validated_elements: List[Any] = [] + for element_factory in elements: + element = element_factory(self._app, properties) + if not element: + continue + validated_elements.append(element) + return validated_elements + @model_validator(mode="after") def add_hypermodels_to_entities(self: Self) -> Self: entities: list[SirenEmbeddedType | SirenLinkType] = [] @@ -482,7 +482,9 @@ def get_siren_link(response: Any, link_name: str) -> SirenLinkType | None: def get_siren_action(response: Any, action_name: str) -> SirenActionType | None: actions = response.get("actions", []) - action = next((action for action in actions if action_name in action.get("name")), {}) + action = next( + (action for action in actions if action_name in action.get("name")), {} + ) if not action: return None - return SirenActionType.model_validate(action) \ No newline at end of file + return SirenActionType.model_validate(action) diff --git a/tests/integration/siren/test_siren_items.py b/tests/integration/siren/test_siren_items.py index fd1d3ee..00928be 100644 --- a/tests/integration/siren/test_siren_items.py +++ b/tests/integration/siren/test_siren_items.py @@ -131,9 +131,6 @@ def test_get_item( item: Item, ) -> None: find_uri = item.parse_uri(find_uri_template.href) - - assert find_uri_template.method - item_response = siren_client.request(find_uri_template.method, find_uri).json() item_href = get_siren_link(item_response, "self") @@ -181,9 +178,6 @@ def test_update_item_from_update_uri( siren_client: TestClient, find_uri_template: SirenActionType, item: Item ) -> None: find_uri = item.parse_uri(find_uri_template.href) - - assert find_uri_template.method - before = siren_client.request(find_uri_template.method, find_uri).json() new_data = {"name": f"updated_{uuid.uuid4().hex}"} @@ -191,8 +185,6 @@ def test_update_item_from_update_uri( update_action = get_siren_action(before, "update") assert update_action - assert update_action.method - assert update_action.href response = siren_client.request(update_action.method, update_action.href, json=new_data).json() diff --git a/tests/integration/siren/test_siren_people.py b/tests/integration/siren/test_siren_people.py index f4f91c4..ec3a73e 100644 --- a/tests/integration/siren/test_siren_people.py +++ b/tests/integration/siren/test_siren_people.py @@ -1,5 +1,5 @@ import uuid -from typing import Any, Mapping, Sequence +from typing import Any, Mapping import pytest from fastapi.testclient import TestClient @@ -14,31 +14,28 @@ def people_uri() -> str: @pytest.fixture() -def find_uri_template(siren_client: TestClient, people_uri: str) -> SirenActionType: +def find_action(siren_client: TestClient, people_uri: str) -> SirenActionType: reponse = siren_client.get(people_uri).json() - find_uri = get_siren_action(reponse, "find") - assert find_uri - return find_uri + find_action_ = get_siren_action(reponse, "find") + assert find_action_ + return find_action_ @pytest.fixture() -def update_uri_template(siren_client: TestClient, people_uri: str) -> SirenActionType: +def update_action(siren_client: TestClient, people_uri: str) -> SirenActionType: reponse = siren_client.get(people_uri).json() - update_uri = get_siren_action(reponse, "update") - assert update_uri - return update_uri + update_action_ = get_siren_action(reponse, "update") + assert update_action_ + return update_action_ @pytest.fixture() def person_response( siren_client: TestClient, - find_uri_template: SirenActionType, + find_action: SirenActionType, person: Person ) -> Mapping[str, Any]: - find_uri = person.parse_uri(find_uri_template.href) - - assert find_uri_template.method - - return siren_client.request(find_uri_template.method, find_uri).json() + find_uri = person.parse_uri(find_action.href) + return siren_client.request(find_action.method, find_uri).json() def test_people_content_type(siren_client: TestClient, people_uri: str) -> None: @@ -54,14 +51,14 @@ def test_get_people(siren_client: TestClient, people_uri: str) -> None: assert len(people) == 2 assert all(person.get("rel") == "people" for person in people) - self_uri = get_siren_link(response, "self") - assert self_uri - assert self_uri.href == people_uri + self_link = get_siren_link(response, "self") + assert self_link + assert self_link.href == people_uri - find_uri = get_siren_action(response, "find") - assert find_uri - assert find_uri.templated - assert people_uri in find_uri.href + find_action = get_siren_action(response, "find") + assert find_action + assert find_action.templated + assert people_uri in find_action.href def test_get_person( @@ -69,16 +66,14 @@ def test_get_person( person: Person, people_uri: str, ) -> None: - person_self_link = get_siren_link(person_response, "self") + self_link = get_siren_link(person_response, "self") - assert person_self_link - - assert people_uri in person_self_link.href - + assert self_link + assert people_uri in self_link.href assert person.properties person_id = person.properties.get("id_", "") - assert person_id in person_self_link.href + assert person_id in self_link.href assert person_response.get("properties", {}).get("id_") == person_id entities = person_response.get("entities") @@ -89,25 +84,23 @@ def test_get_person( def test_update_person_from_uri_template( siren_client: TestClient, person_response: Mapping[str, Any], - update_uri_template: SirenActionType, + update_action: SirenActionType, person: Person, ) -> None: new_data = {"name": f"updated_{uuid.uuid4().hex}"} - update_uri = person.parse_uri(update_uri_template.href) - - assert update_uri_template.method - response = siren_client.request(update_uri_template.method, update_uri, json=new_data).json() + update_uri = person.parse_uri(update_action.href) + response = siren_client.request(update_action.method, update_uri, json=new_data).json() assert response.get("properties").get("name") == new_data.get("name") assert response.get("properties").get("name") != person_response.get("name") - person_response_uri = get_siren_link(person_response, "self") - after_uri = get_siren_link(response, "self") + self_link = get_siren_link(person_response, "self") + after_link = get_siren_link(response, "self") - assert person_response_uri - assert after_uri - assert person_response_uri.href == after_uri.href + assert self_link + assert after_link + assert self_link.href == after_link.href def test_update_person_from_update_uri( @@ -117,33 +110,32 @@ def test_update_person_from_update_uri( update_action = get_siren_action(person_response, "update") assert update_action - assert update_action.method response = siren_client.request(update_action.method, update_action.href, json=new_data).json() assert response.get("properties").get("name") == new_data.get("name") assert response.get("properties").get("name") != person_response.get("name") - person_response_uri = get_siren_link(person_response, "self") - after_uri = get_siren_link(response, "self") + self_link = get_siren_link(person_response, "self") + after_link = get_siren_link(response, "self") - assert person_response_uri - assert after_uri - assert person_response_uri.href == after_uri.href + assert self_link + assert after_link + assert self_link.href == after_link.href def test_get_person_items( siren_client: TestClient, person_response: Mapping[str, Any] ) -> None: - person_items: Sequence[Mapping[str, str]] = person_response.get("entities", []) + person_items = person_response.get("entities", []) assert person_items assert isinstance(person_items, list) assert all(item.get("rel") == "items" for item in person_items) first_item, *_ = person_items - first_item_uri = get_siren_link(first_item, "self") - assert first_item_uri - first_item_response = siren_client.get(first_item_uri.href).json() + first_item_link = get_siren_link(first_item, "self") + assert first_item_link + first_item_response = siren_client.get(first_item_link.href).json() first_item_response.update({"rel": "items"}) assert first_item == first_item_response @@ -161,28 +153,23 @@ def non_existing_item() -> Any: def test_add_item_to_unlocked_person( siren_client: TestClient, - find_uri_template: SirenActionType, + find_action: SirenActionType, unlocked_person: Person, existing_item: Any, ) -> None: - find_uri = unlocked_person.parse_uri(find_uri_template.href) - - assert find_uri_template.method - before = siren_client.request(find_uri_template.method, find_uri).json() + find_uri = unlocked_person.parse_uri(find_action.href) + before = siren_client.request(find_action.method, find_uri).json() before_items = before.get("entities", {}) - add_item_action = get_siren_action(before, "add_item") + add_item = get_siren_action(before, "add_item") - assert add_item_action - assert add_item_action.method - assert add_item_action.fields + assert add_item + assert add_item.fields - for required_field in add_item_action.fields: + for required_field in add_item.fields: assert existing_item.get(required_field.name) - after = siren_client.request( - add_item_action.method, add_item_action.href, json=existing_item - ).json() + after = siren_client.request(add_item.method, add_item.href, json=existing_item).json() after_items = after.get("entities", {}) assert after_items @@ -195,24 +182,18 @@ def test_add_item_to_unlocked_person( def test_add_item_to_unlocked_person_nonexisting_item( siren_client: TestClient, - find_uri_template: SirenActionType, + find_action: SirenActionType, unlocked_person: Person, non_existing_item: Any, ) -> None: - find_uri = unlocked_person.parse_uri(find_uri_template.href) - - assert find_uri_template.method - - before = siren_client.request(find_uri_template.method, find_uri).json() + find_uri = unlocked_person.parse_uri(find_action.href) + before = siren_client.request(find_action.method, find_uri).json() - add_item_action = get_siren_action(before, "add_item") + add_item = get_siren_action(before, "add_item") - assert add_item_action - assert add_item_action.method + assert add_item - response = siren_client.request( - add_item_action.method, add_item_action.href, json=non_existing_item - ) + response = siren_client.request(add_item.method, add_item.href, json=non_existing_item) assert response.status_code == 404 assert response.json() == {"detail": "No item found with id item05"} @@ -220,14 +201,11 @@ def test_add_item_to_unlocked_person_nonexisting_item( def test_add_item_to_locked_person( siren_client: TestClient, - find_uri_template: SirenActionType, + find_action: SirenActionType, locked_person: Person, ) -> None: - find_uri = locked_person.parse_uri(find_uri_template.href) - - assert find_uri_template.method - - before = siren_client.request(find_uri_template.method, find_uri).json() + find_uri = locked_person.parse_uri(find_action.href) + before = siren_client.request(find_action.method, find_uri).json() add_item_action = get_siren_link(before, "add_item") From 5eed528de95ca221e9673e5a086e04218e028586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Tue, 16 Jan 2024 22:42:46 -0300 Subject: [PATCH 15/66] Ruff formatting --- fastapi_hypermodel/siren.py | 5 ++-- tests/integration/siren/test_siren_items.py | 10 +++++--- tests/integration/siren/test_siren_people.py | 27 ++++++++++++-------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index be1b303..f9d398f 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -6,7 +6,6 @@ Any, Callable, Dict, - List, Literal, Mapping, Optional, @@ -408,8 +407,8 @@ def add_actions(self: Self) -> Self: def _validate_factory( self: Self, elements: Sequence[T], properties: Mapping[str, str] - ) -> List[T]: - validated_elements: List[Any] = [] + ) -> list[T]: + validated_elements: list[Any] = [] for element_factory in elements: element = element_factory(self._app, properties) if not element: diff --git a/tests/integration/siren/test_siren_items.py b/tests/integration/siren/test_siren_items.py index 00928be..227c96a 100644 --- a/tests/integration/siren/test_siren_items.py +++ b/tests/integration/siren/test_siren_items.py @@ -5,7 +5,7 @@ from fastapi.testclient import TestClient from examples.siren import Item -from fastapi_hypermodel import get_siren_action, get_siren_link, SirenActionType +from fastapi_hypermodel import SirenActionType, get_siren_action, get_siren_link @pytest.fixture() @@ -160,7 +160,9 @@ def test_update_item_from_uri_template( update_uri = item.parse_uri(update_uri_template.href) assert update_uri_template.method - response = siren_client.request(update_uri_template.method, update_uri, json=new_data).json() + response = siren_client.request( + update_uri_template.method, update_uri, json=new_data + ).json() name = response.get("properties", {}).get("name") assert name == new_data.get("name") @@ -186,7 +188,9 @@ def test_update_item_from_update_uri( assert update_action - response = siren_client.request(update_action.method, update_action.href, json=new_data).json() + response = siren_client.request( + update_action.method, update_action.href, json=new_data + ).json() name = response.get("properties", {}).get("name") assert name == new_data.get("name") diff --git a/tests/integration/siren/test_siren_people.py b/tests/integration/siren/test_siren_people.py index ec3a73e..845f3fc 100644 --- a/tests/integration/siren/test_siren_people.py +++ b/tests/integration/siren/test_siren_people.py @@ -5,7 +5,7 @@ from fastapi.testclient import TestClient from examples.siren import Person -from fastapi_hypermodel import get_siren_action, get_siren_link, SirenActionType +from fastapi_hypermodel import SirenActionType, get_siren_action, get_siren_link @pytest.fixture() @@ -28,11 +28,10 @@ def update_action(siren_client: TestClient, people_uri: str) -> SirenActionType: assert update_action_ return update_action_ + @pytest.fixture() def person_response( - siren_client: TestClient, - find_action: SirenActionType, - person: Person + siren_client: TestClient, find_action: SirenActionType, person: Person ) -> Mapping[str, Any]: find_uri = person.parse_uri(find_action.href) return siren_client.request(find_action.method, find_uri).json() @@ -67,7 +66,7 @@ def test_get_person( people_uri: str, ) -> None: self_link = get_siren_link(person_response, "self") - + assert self_link assert people_uri in self_link.href assert person.properties @@ -90,14 +89,16 @@ def test_update_person_from_uri_template( new_data = {"name": f"updated_{uuid.uuid4().hex}"} update_uri = person.parse_uri(update_action.href) - response = siren_client.request(update_action.method, update_uri, json=new_data).json() + response = siren_client.request( + update_action.method, update_uri, json=new_data + ).json() assert response.get("properties").get("name") == new_data.get("name") assert response.get("properties").get("name") != person_response.get("name") self_link = get_siren_link(person_response, "self") after_link = get_siren_link(response, "self") - + assert self_link assert after_link assert self_link.href == after_link.href @@ -110,7 +111,9 @@ def test_update_person_from_update_uri( update_action = get_siren_action(person_response, "update") assert update_action - response = siren_client.request(update_action.method, update_action.href, json=new_data).json() + response = siren_client.request( + update_action.method, update_action.href, json=new_data + ).json() assert response.get("properties").get("name") == new_data.get("name") assert response.get("properties").get("name") != person_response.get("name") @@ -169,7 +172,9 @@ def test_add_item_to_unlocked_person( for required_field in add_item.fields: assert existing_item.get(required_field.name) - after = siren_client.request(add_item.method, add_item.href, json=existing_item).json() + after = siren_client.request( + add_item.method, add_item.href, json=existing_item + ).json() after_items = after.get("entities", {}) assert after_items @@ -193,7 +198,9 @@ def test_add_item_to_unlocked_person_nonexisting_item( assert add_item - response = siren_client.request(add_item.method, add_item.href, json=non_existing_item) + response = siren_client.request( + add_item.method, add_item.href, json=non_existing_item + ) assert response.status_code == 404 assert response.json() == {"detail": "No item found with id item05"} From 3b8850dde5732a456cfca261c87933a8f0357e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 20 Jan 2024 22:12:06 -0300 Subject: [PATCH 16/66] Simplify parse_uri logic --- fastapi_hypermodel/hypermodel.py | 7 +++- fastapi_hypermodel/siren.py | 67 ++++++++++++++------------------ 2 files changed, 34 insertions(+), 40 deletions(-) diff --git a/fastapi_hypermodel/hypermodel.py b/fastapi_hypermodel/hypermodel.py index 6181814..de23a35 100644 --- a/fastapi_hypermodel/hypermodel.py +++ b/fastapi_hypermodel/hypermodel.py @@ -99,7 +99,7 @@ def init_app(cls: Type[Self], app: Starlette) -> None: """ cls._app = app - def parse_uri(self: Self, uri_template: str) -> str: + def _parse_uri(self: Self, values: Any, uri_template: str) -> str: parameters: Dict[str, str] = {} for _, field, *_ in Formatter().parse(uri_template): @@ -107,6 +107,9 @@ def parse_uri(self: Self, uri_template: str) -> str: error_message = "Empty Fields Cannot be Processed" raise ValueError(error_message) - parameters[field] = extract_value_by_name(self, field) + parameters[field] = extract_value_by_name(values, field) return uri_template.format(**parameters) + + def parse_uri(self: Self, uri_template: str) -> str: + return self._parse_uri(self, uri_template) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index f9d398f..c2710fe 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -1,7 +1,6 @@ from __future__ import annotations from itertools import starmap -from string import Formatter from typing import ( Any, Callable, @@ -33,7 +32,6 @@ from fastapi_hypermodel.hypermodel import AbstractHyperField, HasName, HyperModel from fastapi_hypermodel.url_type import UrlType from fastapi_hypermodel.utils import ( - extract_value_by_name, get_route_from_app, resolve_param_values, ) @@ -331,6 +329,13 @@ class SirenEmbeddedType(SirenEntityType): T = TypeVar("T", bound=Callable[..., Any]) +SIREN_RESERVED_FIELDS = { + "properties", + "entities", + "links", + "actions", +} + class SirenHyperModel(HyperModel): properties: dict[str, Any] | None = None @@ -345,6 +350,11 @@ class SirenHyperModel(HyperModel): def add_properties(self: Self) -> Self: properties = {} for name, field in self: + alias = self.model_fields[name].alias or name + + if alias in SIREN_RESERVED_FIELDS: + continue + value: Sequence[Any] = field if isinstance(field, Sequence) else [field] omit_types: Any = ( @@ -356,16 +366,6 @@ def add_properties(self: Self) -> Self: if any(isinstance(value_, omit_types) for value_ in value): continue - built_in_types = { - "properties", - "entities", - "links_", - "actions_", - } - if name in built_in_types: - continue - - alias = self.model_fields[name].alias or name properties[alias] = value if isinstance(field, Sequence) else field delattr(self, name) @@ -380,9 +380,9 @@ def add_properties(self: Self) -> Self: @model_validator(mode="after") def add_links(self: Self) -> Self: for name, value in self: - key = self.model_fields[name].alias or name + alias = self.model_fields[name].alias or name - if key != "links" or not value: + if alias != "links" or not value: continue links = cast(Sequence[SirenLinkFor], value) @@ -428,36 +428,31 @@ def add_hypermodels_to_entities(self: Self) -> Self: continue rel = self.model_fields[name].alias or name - embedded = [self.as_embedded(field, rel) for field in value] - entities.extend(embedded) + + for field in value: + if isinstance(field, SirenLinkType): + entities.append(field) + continue + + child = self.as_embedded(field, rel) + entities.append(child) + delattr(self, name) self.entities = entities - if not self.entities: - delattr(self, "entities") - return self @model_serializer def serialize(self: Self) -> Mapping[str, Any]: - return {self.model_fields[k].alias or k: v for k, v in self} + return {self.model_fields[k].alias or k: v for k, v in self if v} @staticmethod def as_embedded(field: SirenHyperModel, rel: str) -> SirenEmbeddedType: return SirenEmbeddedType(rel=rel, **field.model_dump()) def parse_uri(self: Self, uri_template: str) -> str: - parameters: dict[str, str] = {} - - for _, field, *_ in Formatter().parse(uri_template): - if not field: - error_message = "Empty Fields Cannot be Processed" - raise ValueError(error_message) - - parameters[field] = extract_value_by_name(self.properties, field) - - return uri_template.format(**parameters) + return self._parse_uri(self.properties, uri_template) class SirenResponse(JSONResponse): @@ -473,17 +468,13 @@ def render(self: Self, content: Any) -> bytes: def get_siren_link(response: Any, link_name: str) -> SirenLinkType | None: links = response.get("links", []) - link = next((link for link in links if link_name in link.get("rel")), {}) - if not link: - return None - return SirenLinkType.model_validate(link) + link = next((link for link in links if link_name in link.get("rel")), None) + return SirenLinkType.model_validate(link) if link else None def get_siren_action(response: Any, action_name: str) -> SirenActionType | None: actions = response.get("actions", []) action = next( - (action for action in actions if action_name in action.get("name")), {} + (action for action in actions if action_name in action.get("name")), None ) - if not action: - return None - return SirenActionType.model_validate(action) + return SirenActionType.model_validate(action) if action else None From 869d828ee1422c64628aed32db8c3353613ccbdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 20 Jan 2024 23:32:54 -0300 Subject: [PATCH 17/66] Dereference schemas to avoid missing definitions --- fastapi_hypermodel/hypermodel.py | 6 +++++- pyproject.toml | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/fastapi_hypermodel/hypermodel.py b/fastapi_hypermodel/hypermodel.py index de23a35..f8a480d 100644 --- a/fastapi_hypermodel/hypermodel.py +++ b/fastapi_hypermodel/hypermodel.py @@ -1,3 +1,4 @@ +import json from abc import ABC, abstractmethod from string import Formatter from typing import ( @@ -15,6 +16,7 @@ runtime_checkable, ) +import jsonref import pydantic_core from pydantic import ( BaseModel, @@ -54,7 +56,9 @@ def __schema_subclasses__( continue schema = subclass.model_json_schema() - subclasses_schemas.append(schema) + deref_schema: Dict[str, Any] = jsonref.loads(json.dumps(schema)) # type: ignore + + subclasses_schemas.append(deref_schema) return subclasses_schemas diff --git a/pyproject.toml b/pyproject.toml index adf3d3e..ec5254f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ fastapi = ">=0.65.2" pydantic = ">=2.0,<3.0" typing_extensions = ">=4.0.0" python = ">=3.8,<4.0" +jsonref = ">=1.1.0,<2.0.0" [tool.poetry.group.dev.dependencies] bandit = "^1.7.0" From 62e2dbe2cbbe04d11cbb414d0d779bd1e3fccf12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 21 Jan 2024 00:11:16 -0300 Subject: [PATCH 18/66] Add some SirenLinkFor tests --- tests/test_hal.py | 5 +++- tests/test_siren.py | 71 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 tests/test_siren.py diff --git a/tests/test_hal.py b/tests/test_hal.py index 5028e30..01ebb76 100644 --- a/tests/test_hal.py +++ b/tests/test_hal.py @@ -448,7 +448,10 @@ def test_hal_for(hal_app: FastAPI) -> None: mock = MockClass(id_="test") hal_for = HALFor("mock_read_with_path_hal", {"id_": ""}) - hal_for(hal_app, vars(mock)) + hal_for_type = hal_for(hal_app, vars(mock)) + + assert isinstance(hal_for_type, HALForType) + assert hal_for_type.href == "/mock_read_with_path/test" @pytest.mark.usefixtures("hal_app") diff --git a/tests/test_siren.py b/tests/test_siren.py new file mode 100644 index 0000000..3373f0d --- /dev/null +++ b/tests/test_siren.py @@ -0,0 +1,71 @@ +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fastapi_hypermodel import ( + SirenHyperModel, + SirenLinkFor, + SirenLinkType, + SirenResponse, +) + +SAMPLE_ENDPOINT = "/mock_read_with_path_siren/{id_}" + + +class MockClass(SirenHyperModel): + id_: str + + +@pytest.fixture() +def sample_endpoint_uri() -> str: + return SAMPLE_ENDPOINT + + +@pytest.fixture() +def siren_app(app: FastAPI, sample_endpoint_uri: str) -> FastAPI: + @app.get(sample_endpoint_uri, response_class=SirenResponse) + def mock_read_with_path_siren() -> Any: # pragma: no cover + return {} + + SirenHyperModel.init_app(app) + + return app + + +@pytest.fixture() +def siren_client(siren_app: FastAPI) -> TestClient: + return TestClient(app=siren_app) + + +def test_content_type(siren_client: TestClient) -> None: + response = siren_client.get("mock_read_with_path_siren/test") + + content_type = response.headers.get("content-type") + assert content_type == "application/siren+json" + + +# SirenLinkFor + + +def test_siren_link_for(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_link_for = SirenLinkFor( + "mock_read_with_path_siren", {"id_": ""}, rel=["test"] + ) + siren_link_for_type = siren_link_for(siren_app, vars(mock)) + + assert isinstance(siren_link_for_type, SirenLinkType) + assert siren_link_for_type.href == "/mock_read_with_path_siren/test" + assert siren_link_for_type.rel == ["test"] + + +def test_siren_link_for_missing_rel(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_link_for = SirenLinkFor("mock_read_with_path_siren", {"id_": ""}) + + with pytest.raises(ValueError, match="Field rel and href are mandatory"): + siren_link_for(siren_app, vars(mock)) From dae9714c839545965a8a5b2050ecf975735e9458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 21 Jan 2024 22:49:22 -0300 Subject: [PATCH 19/66] Add more unit tests for Links, Actions and Fields --- fastapi_hypermodel/__init__.py | 2 + fastapi_hypermodel/siren.py | 88 +++-- tests/integration/siren/test_siren_items.py | 2 +- tests/test_siren.py | 362 +++++++++++++++++++- 4 files changed, 420 insertions(+), 34 deletions(-) diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index bb5102b..8b03e7b 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -8,6 +8,7 @@ from .siren import ( SirenActionFor, SirenActionType, + SirenFieldType, SirenHyperModel, SirenLinkFor, SirenLinkType, @@ -36,6 +37,7 @@ "HalHyperModel", "SirenActionType", "SirenActionFor", + "SirenFieldType", "SirenLinkFor", "SirenLinkType", "SirenHyperModel", diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index c2710fe..5459c7e 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -7,7 +7,6 @@ Dict, Literal, Mapping, - Optional, Sequence, TypeVar, cast, @@ -218,11 +217,11 @@ def __init__( condition: Callable[[Mapping[str, Any]], bool] | None = None, populate_fields: bool = True, title: str | None = None, - type_: str = "application/x-www-form-urlencoded", + type_: str | None = None, class_: Sequence[str] | None = None, fields: Sequence[SirenFieldType] | None = None, method: str | None = None, - name: str | None = None, + name: str | None = "", **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -262,21 +261,17 @@ def _prepopulate_fields( def _compute_fields( self: Self, route: Route, values: Mapping[str, Any] ) -> list[SirenFieldType]: - if not isinstance(route, APIRoute): - return [] + if not isinstance(route, APIRoute): # pragma: no cover + route.body_field = "" # type: ignore + route = cast(APIRoute, route) body_field = route.body_field if not body_field: return [] - annotation = body_field.field_info.annotation - - if not annotation: - return [] - - model_fields = cast(Optional[Dict[str, FieldInfo]], annotation.model_fields) # type: ignore - if not model_fields: - return [] + annotation = body_field.field_info.annotation or {} + model_fields = annotation.model_fields if annotation else {} # type: ignore + model_fields = cast(Dict[str, FieldInfo], model_fields) fields = list(starmap(SirenFieldType.from_field_info, model_fields.items())) return self._prepopulate_fields(fields, values) @@ -346,6 +341,33 @@ class SirenHyperModel(HyperModel): # This config is needed to use the Self in Embedded model_config = ConfigDict(arbitrary_types_allowed=True) + @model_validator(mode="after") + def add_hypermodels_to_entities(self: Self) -> Self: + entities: list[SirenEmbeddedType | SirenLinkType] = [] + for name, field in self: + value: Sequence[Any | Self] = ( + field if isinstance(field, Sequence) else [field] + ) + + if not all(isinstance(element, SirenHyperModel) for element in value): + continue + + rel = self.model_fields[name].alias or name + + for field in value: + if isinstance(field, SirenLinkType): + entities.append(field) + continue + + child = self.as_embedded(field, rel) + entities.append(child) + + delattr(self, name) + + self.entities = entities + + return self + @model_validator(mode="after") def add_properties(self: Self) -> Self: properties = {} @@ -417,29 +439,35 @@ def _validate_factory( return validated_elements @model_validator(mode="after") - def add_hypermodels_to_entities(self: Self) -> Self: - entities: list[SirenEmbeddedType | SirenLinkType] = [] - for name, field in self: - value: Sequence[Any | Self] = ( - field if isinstance(field, Sequence) else [field] - ) - - if not all(isinstance(element, SirenHyperModel) for element in value): + def no_hypermodels_outside_of_entities(self: Self) -> Self: + for _, field in self: + if not isinstance(field, (SirenHyperModel, AbstractHyperField)): continue - rel = self.model_fields[name].alias or name + error_message = "All hypermodels must be inside the entities property" + raise ValueError(error_message) - for field in value: - if isinstance(field, SirenLinkType): - entities.append(field) - continue + return self - child = self.as_embedded(field, rel) - entities.append(child) + @model_validator(mode="after") + def no_action_outside_of_actions(self: Self) -> Self: + for _, field in self: + if not isinstance(field, (SirenActionFor, SirenActionType)): + continue - delattr(self, name) + error_message = "All actions must be inside the actions property" + raise ValueError(error_message) - self.entities = entities + return self + + @model_validator(mode="after") + def no_link_outside_of_links(self: Self) -> Self: + for _, field in self: + if not isinstance(field, (SirenLinkFor, SirenLinkType)): + continue + + error_message = "All links must be inside the links property" + raise ValueError(error_message) return self diff --git a/tests/integration/siren/test_siren_items.py b/tests/integration/siren/test_siren_items.py index 227c96a..817e4bb 100644 --- a/tests/integration/siren/test_siren_items.py +++ b/tests/integration/siren/test_siren_items.py @@ -57,7 +57,7 @@ def test_items_content_type(siren_client: TestClient, item_uri: str) -> None: assert first["name"] assert not isinstance(first["name"], list) assert first["href"] - assert len(first) == 5 + assert len(first) == 4 assert len(second) == 6 fields = second.get("fields") diff --git a/tests/test_siren.py b/tests/test_siren.py index 3373f0d..6ada9ae 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -1,10 +1,15 @@ -from typing import Any +from typing import Any, Optional import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from pydantic import BaseModel +from pydantic.fields import FieldInfo from fastapi_hypermodel import ( + SirenActionFor, + SirenActionType, + SirenFieldType, SirenHyperModel, SirenLinkFor, SirenLinkType, @@ -18,6 +23,11 @@ class MockClass(SirenHyperModel): id_: str +class MockParams(BaseModel): + name: str + lenght: float + + @pytest.fixture() def sample_endpoint_uri() -> str: return SAMPLE_ENDPOINT @@ -29,6 +39,12 @@ def siren_app(app: FastAPI, sample_endpoint_uri: str) -> FastAPI: def mock_read_with_path_siren() -> Any: # pragma: no cover return {} + @app.get("siren_with_body", response_class=SirenResponse) + def mock_read_with_path_siren_with_hypermodel( + mock: MockParams, + ) -> Any: # pragma: no cover + return mock.model_dump() + SirenHyperModel.init_app(app) return app @@ -55,17 +71,357 @@ def test_siren_link_for(siren_app: FastAPI) -> None: siren_link_for = SirenLinkFor( "mock_read_with_path_siren", {"id_": ""}, rel=["test"] ) - siren_link_for_type = siren_link_for(siren_app, vars(mock)) + assert mock.properties + + siren_link_for_type = siren_link_for(siren_app, mock.properties) + + assert isinstance(siren_link_for_type, SirenLinkType) + assert siren_link_for_type.href == "/mock_read_with_path_siren/test" + assert siren_link_for_type.rel == ["test"] + + +def test_siren_link_for_serialize(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_link_for = SirenLinkFor( + "mock_read_with_path_siren", {"id_": ""}, rel=["test"] + ) + assert mock.properties + siren_link_for_type = siren_link_for(siren_app, mock.properties) + + assert siren_link_for_type + + siren_link_for_type_dict = siren_link_for_type.model_dump() + assert siren_link_for_type_dict.get("href") == "/mock_read_with_path_siren/test" + assert siren_link_for_type_dict.get("rel") == ["test"] + assert siren_link_for_type_dict.get("title") is None + + +def test_siren_link_for_serialize_with_optional_fields(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_link_for = SirenLinkFor( + "mock_read_with_path_siren", {"id_": ""}, rel=["test"], title="test" + ) + assert mock.properties + siren_link_for_type = siren_link_for(siren_app, mock.properties) + + assert siren_link_for_type + + siren_link_for_type_dict = siren_link_for_type.model_dump() + assert siren_link_for_type_dict.get("href") == "/mock_read_with_path_siren/test" + assert siren_link_for_type_dict.get("rel") == ["test"] + assert siren_link_for_type_dict.get("title") == "test" + + +def test_siren_link_for_condition(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_link_for = SirenLinkFor( + "mock_read_with_path_siren", + {"id_": ""}, + rel=["test"], + condition=lambda values: "id_" in values, + ) + assert mock.properties + siren_link_for_type = siren_link_for(siren_app, mock.properties) assert isinstance(siren_link_for_type, SirenLinkType) assert siren_link_for_type.href == "/mock_read_with_path_siren/test" assert siren_link_for_type.rel == ["test"] +def test_siren_link_for_condition_false(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_link_for = SirenLinkFor( + "mock_read_with_path_siren", + {"id_": ""}, + rel=["test"], + condition=lambda values: "id_" not in values, + ) + assert mock.properties + siren_link_for_type = siren_link_for(siren_app, mock.properties) + + assert siren_link_for_type is None + + +def test_siren_link_for_no_app() -> None: + mock = MockClass(id_="test") + + siren_link_for = SirenLinkFor( + "mock_read_with_path_siren", {"id_": ""}, rel=["test"] + ) + assert mock.properties + siren_link_for_type = siren_link_for(None, mock.properties) + + assert siren_link_for_type is None + + +def test_siren_link_for_templated(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_link_for = SirenLinkFor( + "mock_read_with_path_siren", {"id_": ""}, rel=["test"], templated=True + ) + + assert mock.properties + siren_link_for_type = siren_link_for(siren_app, mock.properties) + + assert isinstance(siren_link_for_type, SirenLinkType) + assert siren_link_for_type.href == "/mock_read_with_path_siren/{id_}" + assert siren_link_for_type.rel == ["test"] + + def test_siren_link_for_missing_rel(siren_app: FastAPI) -> None: mock = MockClass(id_="test") siren_link_for = SirenLinkFor("mock_read_with_path_siren", {"id_": ""}) + assert mock.properties + with pytest.raises(ValueError, match="Field rel and href are mandatory"): - siren_link_for(siren_app, vars(mock)) + siren_link_for(siren_app, mock.properties) + + +# SirenFieldType + + +def test_siren_field_type() -> None: + siren_field_type = SirenFieldType(name="test_field") + + assert siren_field_type.name == "test_field" + + +def test_siren_field_type_parse_type_text() -> None: + python_type = Optional[str] + html_type = SirenFieldType.parse_type(python_type) + + assert html_type == "text" + + +def test_siren_field_type_parse_type_number() -> None: + python_type = Optional[float] + html_type = SirenFieldType.parse_type(python_type) + + assert html_type == "number" + + +def test_siren_field_type_parse_type_dict() -> None: + python_type = Optional[Any] + html_type = SirenFieldType.parse_type(python_type) + + assert html_type == "text" + + +def test_siren_field_type_from_field_info() -> None: + field_info = FieldInfo() + field_type = SirenFieldType.from_field_info("test", field_info) + + assert isinstance(field_type, SirenFieldType) + assert field_type.name == "test" + assert field_type.type_ == "text" + assert field_type.value is None + + +def test_siren_field_type_from_field_info_with_type() -> None: + field_info = FieldInfo(annotation=Optional[float]) + field_type = SirenFieldType.from_field_info("test", field_info) + + assert isinstance(field_type, SirenFieldType) + assert field_type.name == "test" + assert field_type.type_ == "number" + assert field_type.value is None + + +def test_siren_field_type_from_field_info_with_default() -> None: + field_info = FieldInfo(default="hello") + field_type = SirenFieldType.from_field_info("test", field_info) + + assert isinstance(field_type, SirenFieldType) + assert field_type.name == "test" + assert field_type.type_ == "text" + assert field_type.value == "hello" + + +def test_siren_field_type_from_field_info_with_type_and_default() -> None: + field_info = FieldInfo(annotation=Optional[float], default=10) + field_type = SirenFieldType.from_field_info("test", field_info) + + assert isinstance(field_type, SirenFieldType) + assert field_type.name == "test" + assert field_type.type_ == "number" + assert field_type.value == 10 + + +# SirenActionFor + + +def test_siren_action_for(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_action_for = SirenActionFor( + "mock_read_with_path_siren", {"id_": ""}, name="test" + ) + assert mock.properties + siren_action_for_type = siren_action_for(siren_app, mock.properties) + + assert isinstance(siren_action_for_type, SirenActionType) + assert siren_action_for_type.href == "/mock_read_with_path_siren/test" + assert siren_action_for_type.name == "test" + assert not siren_action_for_type.fields + + +def test_siren_action_for_serialize(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_action_for = SirenActionFor( + "mock_read_with_path_siren", {"id_": ""}, name="test" + ) + assert mock.properties + siren_action_for_type = siren_action_for(siren_app, mock.properties) + + assert siren_action_for_type + + siren_action_for_type_dict = siren_action_for_type.model_dump() + assert siren_action_for_type_dict.get("href") == "/mock_read_with_path_siren/test" + assert siren_action_for_type_dict.get("name") == "test" + assert siren_action_for_type_dict.get("title") is None + assert not siren_action_for_type.fields + + +def test_siren_action_for_serialize_with_optional_fields(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_action_for = SirenActionFor( + "mock_read_with_path_siren", {"id_": ""}, name="test", title="test" + ) + assert mock.properties + siren_action_for_type = siren_action_for(siren_app, mock.properties) + + assert siren_action_for_type + + siren_action_for_type_dict = siren_action_for_type.model_dump() + assert siren_action_for_type_dict.get("href") == "/mock_read_with_path_siren/test" + assert siren_action_for_type_dict.get("name") == "test" + assert siren_action_for_type_dict.get("title") == "test" + assert not siren_action_for_type.fields + + +def test_siren_action_for_no_name(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_action_for = SirenActionFor("mock_read_with_path_siren", {"id_": ""}) + assert mock.properties + + with pytest.raises(ValueError, match="Field name and href are mandatory"): + siren_action_for(siren_app, mock.properties) + + +def test_siren_action_for_no_app() -> None: + mock = MockClass(id_="test") + + siren_action_for = SirenActionFor( + "mock_read_with_path_siren", {"id_": ""}, rel=["test"] + ) + assert mock.properties + siren_action_for_type = siren_action_for(None, mock.properties) + + assert siren_action_for_type is None + + +def test_siren_action_for_condition(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_action_for = SirenActionFor( + "mock_read_with_path_siren", + {"id_": ""}, + name="test", + condition=lambda values: "id_" in values, + ) + assert mock.properties + siren_action_for_type = siren_action_for(siren_app, mock.properties) + + assert isinstance(siren_action_for_type, SirenActionType) + assert siren_action_for_type.href == "/mock_read_with_path_siren/test" + assert siren_action_for_type.name == "test" + assert not siren_action_for_type.fields + + +def test_siren_action_for_condition_false(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_action_for = SirenActionFor( + "mock_read_with_path_siren", + {"id_": ""}, + name="test", + condition=lambda values: "id_" not in values, + ) + assert mock.properties + siren_action_for_type = siren_action_for(siren_app, mock.properties) + + assert siren_action_for_type is None + + +def test_siren_aciton_for_templated(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_aciton_for = SirenActionFor( + "mock_read_with_path_siren", {"id_": ""}, name="test", templated=True + ) + + assert mock.properties + siren_action_for_type = siren_aciton_for(siren_app, mock.properties) + + assert isinstance(siren_action_for_type, SirenActionType) + assert siren_action_for_type.href == "/mock_read_with_path_siren/{id_}" + assert siren_action_for_type.name == "test" + assert not siren_action_for_type.fields + + +def test_siren_action_for_with_fields(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_action_for = SirenActionFor( + "mock_read_with_path_siren_with_hypermodel", name="test" + ) + assert mock.properties + siren_action_for_type = siren_action_for(siren_app, mock.properties) + + assert isinstance(siren_action_for_type, SirenActionType) + assert siren_action_for_type.href == "siren_with_body" + assert siren_action_for_type.name == "test" + + fields = siren_action_for_type.fields + assert fields + assert len(fields) == 2 + assert all(field.name for field in fields) + assert all(field.type_ for field in fields) + assert any(field.name == "name" and field.type_ == "text" for field in fields) + assert any(field.name == "lenght" and field.type_ == "number" for field in fields) + + assert siren_action_for_type.type_ == "application/x-www-form-urlencoded" + + +def test_siren_action_for_with_fields_no_populate(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_action_for = SirenActionFor( + "mock_read_with_path_siren_with_hypermodel", name="test", populate_fields=False + ) + assert mock.properties + siren_action_for_type = siren_action_for(siren_app, mock.properties) + + assert isinstance(siren_action_for_type, SirenActionType) + assert siren_action_for_type.href == "siren_with_body" + assert siren_action_for_type.name == "test" + + fields = siren_action_for_type.fields + assert fields + assert len(fields) == 2 + assert all(field.name for field in fields) + assert all(field.type_ for field in fields) + assert any(field.name == "name" and field.type_ == "text" for field in fields) + assert any(field.name == "lenght" and field.type_ == "number" for field in fields) + + assert siren_action_for_type.type_ == "application/x-www-form-urlencoded" From 375aa895983b95f06f9fa5488499099a823b6b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Mon, 22 Jan 2024 22:31:15 -0300 Subject: [PATCH 20/66] Increase test coverage with unit tests to 100% --- fastapi_hypermodel/siren.py | 115 +++++++------- tests/integration/siren/test_siren_people.py | 6 +- tests/test_siren.py | 157 ++++++++++++++++++- 3 files changed, 220 insertions(+), 58 deletions(-) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 5459c7e..8171f91 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -5,6 +5,7 @@ Any, Callable, Dict, + List, Literal, Mapping, Sequence, @@ -120,13 +121,15 @@ def __call__( uri_path = self._get_uri_path(app, properties, route) # Using model_validate to avoid conflicts with keyword class - return SirenLinkType.model_validate({ - "href": uri_path, - "rel": self._rel, - "title": self._title, - "type": self._type, - "class": self._class, - }) + return SirenLinkType.model_validate( + { + "href": uri_path, + "rel": self._rel, + "title": self._title, + "type": self._type, + "class": self._class, + } + ) FieldType = Literal[ @@ -159,11 +162,13 @@ class SirenFieldType(SirenBase): @classmethod def from_field_info(cls: type[Self], name: str, field_info: FieldInfo) -> Self: - return cls.model_validate({ - "name": name, - "type": cls.parse_type(field_info.annotation), - "value": field_info.default, - }) + return cls.model_validate( + { + "name": name, + "type": cls.parse_type(field_info.annotation), + "value": field_info.default, + } + ) @staticmethod def parse_type(python_type: type[Any] | None) -> FieldType: @@ -299,16 +304,18 @@ def __call__( self._type = "application/x-www-form-urlencoded" # Using model_validate to avoid conflicts with class and type - return SirenActionType.model_validate({ - "href": uri_path, - "name": self._name, - "fields": self._fields, - "method": self._method, - "title": self._title, - "type": self._type, - "class": self._class, - "templated": self._templated, - }) + return SirenActionType.model_validate( + { + "href": uri_path, + "name": self._name, + "fields": self._fields, + "method": self._method, + "title": self._title, + "type": self._type, + "class": self._class, + "templated": self._templated, + } + ) class SirenEntityType(SirenBase): @@ -319,7 +326,7 @@ class SirenEntityType(SirenBase): class SirenEmbeddedType(SirenEntityType): - rel: str = Field() + rel: Sequence[str] = Field() T = TypeVar("T", bound=Callable[..., Any]) @@ -345,21 +352,27 @@ class SirenHyperModel(HyperModel): def add_hypermodels_to_entities(self: Self) -> Self: entities: list[SirenEmbeddedType | SirenLinkType] = [] for name, field in self: + alias = self.model_fields[name].alias or name + + if alias in SIREN_RESERVED_FIELDS: + continue + value: Sequence[Any | Self] = ( field if isinstance(field, Sequence) else [field] ) - if not all(isinstance(element, SirenHyperModel) for element in value): + if not all( + isinstance(element, (SirenHyperModel, SirenLinkType)) + for element in value + ): continue - rel = self.model_fields[name].alias or name - for field in value: if isinstance(field, SirenLinkType): entities.append(field) continue - child = self.as_embedded(field, rel) + child = self.as_embedded(field, alias) entities.append(child) delattr(self, name) @@ -382,7 +395,9 @@ def add_properties(self: Self) -> Self: omit_types: Any = ( AbstractHyperField, SirenLinkFor, + SirenLinkType, SirenActionFor, + SirenActionType, SirenHyperModel, ) if any(isinstance(value_, omit_types) for value_ in value): @@ -401,6 +416,7 @@ def add_properties(self: Self) -> Self: @model_validator(mode="after") def add_links(self: Self) -> Self: + validated_links: list[SirenLinkFor] = [] for name, value in self: alias = self.model_fields[name].alias or name @@ -409,10 +425,25 @@ def add_links(self: Self) -> Self: links = cast(Sequence[SirenLinkFor], value) properties = self.properties or {} - self.links = self._validate_factory(links, properties) + validated_links = self._validate_factory(links, properties) + self.links = validated_links + + self.validate_has_self_link(validated_links) return self + @staticmethod + def validate_has_self_link(links: Sequence[SirenLinkFor]) -> None: + if not links: + return + + if any(link.rel == ["self"] for link in links): + return + + error_message = "If links are present, a link with rel self must be present" + raise ValueError(error_message) + + @model_validator(mode="after") def add_actions(self: Self) -> Self: for name, value in self: @@ -430,25 +461,12 @@ def add_actions(self: Self) -> Self: def _validate_factory( self: Self, elements: Sequence[T], properties: Mapping[str, str] ) -> list[T]: - validated_elements: list[Any] = [] + validated_elements: list[T] = [] for element_factory in elements: element = element_factory(self._app, properties) - if not element: - continue validated_elements.append(element) return validated_elements - @model_validator(mode="after") - def no_hypermodels_outside_of_entities(self: Self) -> Self: - for _, field in self: - if not isinstance(field, (SirenHyperModel, AbstractHyperField)): - continue - - error_message = "All hypermodels must be inside the entities property" - raise ValueError(error_message) - - return self - @model_validator(mode="after") def no_action_outside_of_actions(self: Self) -> Self: for _, field in self: @@ -460,24 +478,13 @@ def no_action_outside_of_actions(self: Self) -> Self: return self - @model_validator(mode="after") - def no_link_outside_of_links(self: Self) -> Self: - for _, field in self: - if not isinstance(field, (SirenLinkFor, SirenLinkType)): - continue - - error_message = "All links must be inside the links property" - raise ValueError(error_message) - - return self - @model_serializer def serialize(self: Self) -> Mapping[str, Any]: return {self.model_fields[k].alias or k: v for k, v in self if v} @staticmethod def as_embedded(field: SirenHyperModel, rel: str) -> SirenEmbeddedType: - return SirenEmbeddedType(rel=rel, **field.model_dump()) + return SirenEmbeddedType(rel=[rel], **field.model_dump()) def parse_uri(self: Self, uri_template: str) -> str: return self._parse_uri(self.properties, uri_template) diff --git a/tests/integration/siren/test_siren_people.py b/tests/integration/siren/test_siren_people.py index 845f3fc..e4d8e49 100644 --- a/tests/integration/siren/test_siren_people.py +++ b/tests/integration/siren/test_siren_people.py @@ -48,7 +48,7 @@ def test_get_people(siren_client: TestClient, people_uri: str) -> None: people = response.get("entities", []) assert len(people) == 2 - assert all(person.get("rel") == "people" for person in people) + assert all(person.get("rel") == ["people"] for person in people) self_link = get_siren_link(response, "self") assert self_link @@ -133,13 +133,13 @@ def test_get_person_items( assert person_items assert isinstance(person_items, list) - assert all(item.get("rel") == "items" for item in person_items) + assert all(item.get("rel") == ["items"] for item in person_items) first_item, *_ = person_items first_item_link = get_siren_link(first_item, "self") assert first_item_link first_item_response = siren_client.get(first_item_link.href).json() - first_item_response.update({"rel": "items"}) + first_item_response.update({"rel": ["items"]}) assert first_item == first_item_response diff --git a/tests/test_siren.py b/tests/test_siren.py index 6ada9ae..2a42b59 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Optional, Sequence import pytest from fastapi import FastAPI @@ -15,6 +15,8 @@ SirenLinkType, SirenResponse, ) +from fastapi_hypermodel.siren import SirenEmbeddedType, get_siren_action, get_siren_link +from fastapi_hypermodel.url_type import UrlType SAMPLE_ENDPOINT = "/mock_read_with_path_siren/{id_}" @@ -28,6 +30,11 @@ class MockParams(BaseModel): lenght: float +class MockClassWithProperties(SirenHyperModel): + id_: str + model: MockClass + + @pytest.fixture() def sample_endpoint_uri() -> str: return SAMPLE_ENDPOINT @@ -425,3 +432,151 @@ def test_siren_action_for_with_fields_no_populate(siren_app: FastAPI) -> None: assert any(field.name == "lenght" and field.type_ == "number" for field in fields) assert siren_action_for_type.type_ == "application/x-www-form-urlencoded" + + +# SirenHypermodel + + +def test_siren_hypermodel_with_properties() -> None: + mock = MockClass(id_="test") + assert mock.properties + assert mock.properties.get("id_") == "test" + + +def test_siren_hypermodel_with_entities_embedded_hypermodel() -> None: + mock = MockClassWithProperties(id_="test", model=MockClass(id_="test_nested")) + assert mock.entities + + first, *_ = mock.entities + assert isinstance(first, SirenEmbeddedType) + assert first.rel == ["model"] + assert first.properties + assert first.properties.get("id_") == "test_nested" + + +def test_siren_hypermodel_with_entities_embedded_link() -> None: + class MockClassWithEmbeddedLink(SirenHyperModel): + id_: str + model: SirenLinkType + + mock = MockClassWithEmbeddedLink( + id_="test", model=SirenLinkType(href=UrlType("test_nested"), rel=["model"]) + ) + assert mock.entities + + first, *_ = mock.entities + assert isinstance(first, SirenLinkType) + assert first.rel == ["model"] + assert first.href == "test_nested" + + +@pytest.mark.usefixtures("siren_app") +def test_siren_hypermodel_with_links() -> None: + class MockClassWithLinks(SirenHyperModel): + id_: str + + links: Sequence[SirenLinkFor] = ( + SirenLinkFor("mock_read_with_path_siren", {"id_": ""}, rel=["self"]), + ) + + mock = MockClassWithLinks(id_="test") + + assert mock.links + + first, *_ = mock.links + assert isinstance(first, SirenLinkType) + assert first.rel == ["self"] + assert first.href == "/mock_read_with_path_siren/test" + + +@pytest.mark.usefixtures("siren_app") +def test_siren_hypermodel_with_links_no_self() -> None: + class MockClassWithLinks(SirenHyperModel): + id_: str + + links: Sequence[SirenLinkFor] = ( + SirenLinkFor("mock_read_with_path_siren", {"id_": ""}, rel=["model"]), + ) + + with pytest.raises( + ValueError, match="If links are present, a link with rel self must be present" + ): + MockClassWithLinks(id_="test") + + +@pytest.mark.usefixtures("siren_app") +def test_siren_hypermodel_with_actions() -> None: + class MockClassWithLinks(SirenHyperModel): + id_: str + + actions: Sequence[SirenActionFor] = ( + SirenActionFor("mock_read_with_path_siren", {"id_": ""}, name="test"), + ) + + mock = MockClassWithLinks(id_="test") + + assert mock.actions + + first, *_ = mock.actions + assert isinstance(first, SirenActionType) + assert first.name == "test" + assert first.href == "/mock_read_with_path_siren/test" + + +@pytest.mark.usefixtures("siren_app") +def test_siren_hypermodel_with_actions_outside_actions() -> None: + class MockClassWithActions(SirenHyperModel): + id_: str + + model_action: SirenActionFor = SirenActionFor( + "mock_read_with_path_siren", templated=True, name="model" + ) + + with pytest.raises( + ValueError, match="All actions must be inside the actions property" + ): + MockClassWithActions(id_="test") + + +def test_siren_parse_uri() -> None: + uri_template = "/model/{id_}" + + mock = MockClass(id_="test") + + assert mock.properties + assert mock.parse_uri(uri_template) == f"/model/{mock.properties.get('id_')}" + + +# Utils + + +@pytest.fixture() +def siren_response() -> Any: + return { + "links": [{"rel": ["self"], "href": "/self"}], + "actions": [{"name": "add", "href": "/self"}], + } + + +def test_get_siren_link_href(siren_response: Any) -> None: + actual = get_siren_link(siren_response, "self") + expected = SirenLinkType(href=UrlType("/self"), rel=["self"]) + + assert actual == expected + + +def test_get_siren_link_href_not_found(siren_response: Any) -> None: + actual = get_siren_link(siren_response, "update") + assert actual is None + + +def test_get_siren_action_href(siren_response: Any) -> None: + actual = get_siren_action(siren_response, "add") + expected = SirenActionType(href=UrlType("/self"), name="add") + + assert actual == expected + + +def test_get_siren_action_href_not_found(siren_response: Any) -> None: + actual = get_siren_action(siren_response, "update") + assert actual is None From 2b8bd2e852848f7128796f9aa887d81aed7fc843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Mon, 22 Jan 2024 22:58:45 -0300 Subject: [PATCH 21/66] Fix edge case with None Actions/Links --- fastapi_hypermodel/siren.py | 54 +++++++++++++++++-------------------- tests/test_siren.py | 14 +++++++++- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 8171f91..7e66ad9 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -5,7 +5,6 @@ Any, Callable, Dict, - List, Literal, Mapping, Sequence, @@ -121,15 +120,13 @@ def __call__( uri_path = self._get_uri_path(app, properties, route) # Using model_validate to avoid conflicts with keyword class - return SirenLinkType.model_validate( - { - "href": uri_path, - "rel": self._rel, - "title": self._title, - "type": self._type, - "class": self._class, - } - ) + return SirenLinkType.model_validate({ + "href": uri_path, + "rel": self._rel, + "title": self._title, + "type": self._type, + "class": self._class, + }) FieldType = Literal[ @@ -162,13 +159,11 @@ class SirenFieldType(SirenBase): @classmethod def from_field_info(cls: type[Self], name: str, field_info: FieldInfo) -> Self: - return cls.model_validate( - { - "name": name, - "type": cls.parse_type(field_info.annotation), - "value": field_info.default, - } - ) + return cls.model_validate({ + "name": name, + "type": cls.parse_type(field_info.annotation), + "value": field_info.default, + }) @staticmethod def parse_type(python_type: type[Any] | None) -> FieldType: @@ -304,18 +299,16 @@ def __call__( self._type = "application/x-www-form-urlencoded" # Using model_validate to avoid conflicts with class and type - return SirenActionType.model_validate( - { - "href": uri_path, - "name": self._name, - "fields": self._fields, - "method": self._method, - "title": self._title, - "type": self._type, - "class": self._class, - "templated": self._templated, - } - ) + return SirenActionType.model_validate({ + "href": uri_path, + "name": self._name, + "fields": self._fields, + "method": self._method, + "title": self._title, + "type": self._type, + "class": self._class, + "templated": self._templated, + }) class SirenEntityType(SirenBase): @@ -443,7 +436,6 @@ def validate_has_self_link(links: Sequence[SirenLinkFor]) -> None: error_message = "If links are present, a link with rel self must be present" raise ValueError(error_message) - @model_validator(mode="after") def add_actions(self: Self) -> Self: for name, value in self: @@ -464,6 +456,8 @@ def _validate_factory( validated_elements: list[T] = [] for element_factory in elements: element = element_factory(self._app, properties) + if not element: + continue validated_elements.append(element) return validated_elements diff --git a/tests/test_siren.py b/tests/test_siren.py index 2a42b59..c599dbe 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -529,7 +529,7 @@ class MockClassWithActions(SirenHyperModel): id_: str model_action: SirenActionFor = SirenActionFor( - "mock_read_with_path_siren", templated=True, name="model" + "mock_read_with_path_siren", {"id_": ""}, name="model" ) with pytest.raises( @@ -537,6 +537,18 @@ class MockClassWithActions(SirenHyperModel): ): MockClassWithActions(id_="test") +@pytest.mark.usefixtures("siren_app") +def test_siren_hypermodel_with_actions_empty() -> None: + class MockClassWithActions(SirenHyperModel): + id_: str + + model_action: SirenActionFor = SirenActionFor( + "mock_read_with_path_siren", {"id_": ""}, name="model", condition=lambda values: "model" in values + ) + + mock = MockClassWithActions(id_="test") + assert not mock.actions_ + def test_siren_parse_uri() -> None: uri_template = "/model/{id_}" From 1ede40980d1f5e2d1e186140ffa0fbc5270a434c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Mon, 22 Jan 2024 23:12:17 -0300 Subject: [PATCH 22/66] Add Response validation against official jsonschema --- fastapi_hypermodel/siren.py | 8 +- fastapi_hypermodel/siren_schema.py | 324 +++++++++++++++++++++++++++++ pyproject.toml | 6 +- tests/test_siren.py | 6 +- 4 files changed, 340 insertions(+), 4 deletions(-) create mode 100644 fastapi_hypermodel/siren_schema.py diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 7e66ad9..b674573 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -12,6 +12,7 @@ cast, ) +import jsonschema from fastapi.routing import APIRoute from pydantic import ( BaseModel, @@ -35,6 +36,8 @@ resolve_param_values, ) +from .siren_schema import schema + class SirenBase(BaseModel): class_: Sequence[str] | None = Field(default=None, alias="class") @@ -255,7 +258,8 @@ def _prepopulate_fields( return list(fields) for field in fields: - field.value = values.get(field.name) or field.value + value = values.get(field.name) or field.value + field.value = str(value) return list(fields) def _compute_fields( @@ -488,7 +492,7 @@ class SirenResponse(JSONResponse): media_type = "application/siren+json" def _validate(self: Self, content: Any) -> None: - pass + jsonschema.validate(instance=content, schema=schema) def render(self: Self, content: Any) -> bytes: self._validate(content) diff --git a/fastapi_hypermodel/siren_schema.py b/fastapi_hypermodel/siren_schema.py new file mode 100644 index 0000000..3946fcd --- /dev/null +++ b/fastapi_hypermodel/siren_schema.py @@ -0,0 +1,324 @@ +# Extracted from https://raw.githubusercontent.com/kevinswiber/siren/v0.6.2/siren.schema.json + +schema = { + "id": "http://sirenspec.org/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Entity", + "description": "An Entity is a URI-addressable resource that has properties and actions associated with it. It may contain sub-entities and navigational links.", + "type": "object", + "properties": { + "class": { + "description": "Describes the nature of an entity's content based on the current representation. Possible values are implementation-dependent and should be documented.", + "type": "array", + "items": {"type": "string"}, + }, + "title": { + "description": "Descriptive text about the entity.", + "type": "string", + }, + "properties": { + "description": "A set of key-value pairs that describe the state of an entity.", + "type": "object", + }, + "entities": { + "description": "A collection of related sub-entities. If a sub-entity contains an href value, it should be treated as an embedded link. Clients may choose to optimistically load embedded links. If no href value exists, the sub-entity is an embedded entity representation that contains all the characteristics of a typical entity. One difference is that a sub-entity MUST contain a rel attribute to describe its relationship to the parent entity.", + "type": "array", + "items": {"$ref": "#/definitions/SubEntity"}, + }, + "actions": { + "description": "A collection of actions; actions show available behaviors an entity exposes.", + "type": "array", + "items": {"$ref": "#/definitions/Action"}, + }, + "links": { + "description": "A collection of items that describe navigational links, distinct from entity relationships. Link items should contain a `rel` attribute to describe the relationship and an `href` attribute to point to the target URI. Entities should include a link `rel` to `self`.", + "type": "array", + "items": {"$ref": "#/definitions/Link"}, + }, + }, + "definitions": { + "SubEntity": { + "anyOf": [ + {"$ref": "#/definitions/EmbeddedLinkSubEntity"}, + {"$ref": "#/definitions/EmbeddedRepresentationSubEntity"}, + ] + }, + "EmbeddedLinkSubEntity": { + "type": "object", + "required": ["rel", "href"], + "properties": { + "class": { + "description": "Describes the nature of an entity's content based on the current representation. Possible values are implementation-dependent and should be documented.", + "type": "array", + "items": {"type": "string"}, + }, + "rel": { + "description": "Defines the relationship of the sub-entity to its parent, per Web Linking (RFC5899).", + "type": "array", + "items": {"$ref": "#/definitions/RelValue"}, + "minItems": 1, + }, + "href": { + "description": "The URI of the linked sub-entity.", + "type": "string", + "format": "uri", + }, + "type": {"$ref": "#/definitions/MediaType"}, + "title": { + "description": "Descriptive text about the entity.", + "type": "string", + }, + }, + }, + "EmbeddedRepresentationSubEntity": { + "allOf": [ + {"$ref": "#"}, + { + "required": ["rel"], + "properties": { + "rel": { + "description": "Defines the relationship of the sub-entity to its parent, per Web Linking (RFC5899).", + "type": "array", + "items": {"$ref": "#/definitions/RelValue"}, + "minItems": 1, + } + }, + }, + ] + }, + "Action": { + "description": "Actions show available behaviors an entity exposes.", + "type": "object", + "required": ["name", "href"], + "properties": { + "class": { + "description": "Describes the nature of an action based on the current representation. Possible values are implementation-dependent and should be documented.", + "type": "array", + "items": {"type": "string"}, + }, + "name": { + "description": "A string that identifies the action to be performed. Action names MUST be unique within the set of actions for an entity. The behaviour of clients when parsing a Siren document that violates this constraint is undefined.", + "type": "string", + }, + "method": { + "description": "An enumerated attribute mapping to a protocol method. For HTTP, these values may be GET, PUT, POST, DELETE, or PATCH. As new methods are introduced, this list can be extended. If this attribute is omitted, GET should be assumed.", + "type": "string", + "enum": ["DELETE", "GET", "PATCH", "POST", "PUT"], + "default": "GET", + }, + "href": { + "description": "The URI of the action.", + "type": "string", + "format": "uri", + }, + "title": { + "description": "Descriptive text about the action.", + "type": "string", + }, + "type": { + "description": "The encoding type for the request. When omitted and the fields attribute exists, the default value is `application/x-www-form-urlencoded`.", + "type": "string", + "default": "application/x-www-form-urlencoded", + }, + "fields": { + "description": "A collection of fields.", + "type": "array", + "items": {"$ref": "#/definitions/Field"}, + }, + }, + }, + "Field": { + "description": "Fields represent controls inside of actions.", + "type": "object", + "required": ["name"], + "properties": { + "name": { + "description": "A name describing the control. Field names MUST be unique within the set of fields for an action. The behaviour of clients when parsing a Siren document that violates this constraint is undefined.", + "type": "string", + }, + "type": { + "description": "The input type of the field. This is a subset of the input types specified by HTML5.", + "type": "string", + "default": "text", + "enum": [ + "hidden", + "text", + "search", + "tel", + "url", + "email", + "password", + "datetime", + "date", + "month", + "week", + "time", + "datetime-local", + "number", + "range", + "color", + "checkbox", + "radio", + "file", + ], + }, + "title": { + "description": "Textual annotation of a field. Clients may use this as a label.", + "type": "string", + }, + "value": { + "description": "A value assigned to the field. May be a scalar value or a list of value objects.", + "oneOf": [ + {"type": ["string", "number"]}, + { + "type": "array", + "items": {"$ref": "#/definitions/FieldValueObject"}, + }, + ], + }, + }, + }, + "FieldValueObject": { + "description": 'Value objects represent multiple selectable field values. Use in conjunction with field `"type" = "radio"` and `"type" = "checkbox"` to express that zero, one or many out of several possible values may be sent back to the server.', + "type": "object", + "required": ["value"], + "properties": { + "title": { + "description": "Textual description of a field value.", + "type": "string", + }, + "value": { + "description": "Possible value for the field.", + "type": ["string", "number"], + }, + "selected": { + "description": 'A value object with a `"selected" = true` attribute indicates that this value should be considered preselected by the client. When missing, the default value is `False`.', + "type": "boolean", + "default": False, + }, + }, + }, + "Link": { + "description": "Links represent navigational transitions.", + "type": "object", + "required": ["rel", "href"], + "properties": { + "class": { + "description": "Describes aspects of the link based on the current representation. Possible values are implementation-dependent and should be documented.", + "type": "array", + "items": {"type": "string"}, + }, + "title": { + "description": "Text describing the nature of a link.", + "type": "string", + }, + "rel": { + "description": "Defines the relationship of the link to its entity, per Web Linking (RFC5988).", + "type": "array", + "items": {"$ref": "#/definitions/RelValue"}, + }, + "href": { + "description": "The URI of the linked resource.", + "type": "string", + "format": "uri", + }, + "type": {"$ref": "#/definitions/MediaType"}, + }, + }, + "MediaType": { + "description": "Defines media type of the linked resource, per Web Linking (RFC5988). For the syntax, see RFC2045 (section 5.1), RFC4288 (section 4.2), RFC6838 (section 4.2)", + "type": "string", + "pattern": "^(application|audio|image|message|model|multipart|text|video)\\/([A-Z]|[a-z]|[0-9]|[\\!\\#\\$\\&\\.\\+\\-\\^\\_]){1,127}(; ?(([\\!\\#\\$\\%\\&\\'\\(\\)\\*\\+-\\.\\/]|[0-9]|[A-Z]|[\\^\\_\\`\\]\\|]|[a-z]|[\\|\\~])+)+=((([\\!\\#\\$\\%\\&\\'\\(\\)\\*\\+-\\.\\/]|[0-9]|[A-Z]|[\\^\\_\\`\\]\\|]|[a-z]|[\\|\\~])+)|\"([\\!\\#\\$\\%\\&\\.\\(\\)\\*\\+\\,\\-\\.\\/]|[0-9]|[\\:\\;\\<\\=\\>\\?\\@]|[A-Z]|[\\[\\\\\\]\\^\\_\\`]|[a-z]|[\\{\\|\\}\\~])+\"))*$", + }, + "RelValue": { + "anyOf": [ + {"type": "string", "format": "uri"}, + { + "type": "string", + "enum": [ + "about", + "alternate", + "appendix", + "archives", + "author", + "blocked-by", + "bookmark", + "canonical", + "chapter", + "collection", + "contents", + "convertedFrom", + "copyright", + "create-form", + "current", + "derivedfrom", + "describedby", + "describes", + "disclosure", + "dns-prefetch", + "duplicate", + "edit", + "edit-form", + "edit-media", + "enclosure", + "first", + "glossary", + "help", + "hosts", + "hub", + "icon", + "index", + "item", + "last", + "latest-version", + "license", + "lrdd", + "memento", + "monitor", + "monitor-group", + "next", + "next-archive", + "nofollow", + "noreferrer", + "original", + "payment", + "pingback", + "preconnect", + "predecessor-version", + "prefetch", + "preload", + "prerender", + "prev", + "preview", + "previous", + "prev-archive", + "privacy-policy", + "profile", + "related", + "restconf", + "replies", + "search", + "section", + "self", + "service", + "start", + "stylesheet", + "subsection", + "successor-version", + "tag", + "terms-of-service", + "timegate", + "timemap", + "type", + "up", + "version-history", + "via", + "webmention", + "working-copy", + "working-copy-of", + ], + }, + ] + }, + }, +} diff --git a/pyproject.toml b/pyproject.toml index ec5254f..dbf8830 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ pydantic = ">=2.0,<3.0" typing_extensions = ">=4.0.0" python = ">=3.8,<4.0" jsonref = ">=1.1.0,<2.0.0" +jsonschema = ">=4.0.0,<5.0.0" [tool.poetry.group.dev.dependencies] bandit = "^1.7.0" @@ -65,7 +66,10 @@ ignore = [ "ANN401", # Ignore Anys "ISC001", # Disable for compatibility with ruff-format ] -extend-exclude = ["tests"] +extend-exclude = [ + "tests", + "*_schema.py" +] target-version = "py38" [tool.ruff.lint] diff --git a/tests/test_siren.py b/tests/test_siren.py index c599dbe..b0319d9 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -537,13 +537,17 @@ class MockClassWithActions(SirenHyperModel): ): MockClassWithActions(id_="test") + @pytest.mark.usefixtures("siren_app") def test_siren_hypermodel_with_actions_empty() -> None: class MockClassWithActions(SirenHyperModel): id_: str model_action: SirenActionFor = SirenActionFor( - "mock_read_with_path_siren", {"id_": ""}, name="model", condition=lambda values: "model" in values + "mock_read_with_path_siren", + {"id_": ""}, + name="model", + condition=lambda values: "model" in values, ) mock = MockClassWithActions(id_="test") From 1f2221f512f365737d0ee4e5742a3007c0ef39e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Mon, 22 Jan 2024 23:18:37 -0300 Subject: [PATCH 23/66] Fix test for non-rendering actions --- tests/test_siren.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_siren.py b/tests/test_siren.py index b0319d9..f2e2217 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -543,11 +543,13 @@ def test_siren_hypermodel_with_actions_empty() -> None: class MockClassWithActions(SirenHyperModel): id_: str - model_action: SirenActionFor = SirenActionFor( - "mock_read_with_path_siren", - {"id_": ""}, - name="model", - condition=lambda values: "model" in values, + actions: Sequence[SirenActionFor] = ( + SirenActionFor( + "mock_read_with_path_siren", + {"id_": ""}, + name="model", + condition=lambda values: "model" in values, + ), ) mock = MockClassWithActions(id_="test") From 637ead0d924614bab1a2cea7b2a522f78a16d12b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Mon, 22 Jan 2024 23:50:02 -0300 Subject: [PATCH 24/66] Fix Pylint and Mypy warnings --- fastapi_hypermodel/hypermodel.py | 6 ++-- fastapi_hypermodel/linkset.py | 5 ++- fastapi_hypermodel/siren.py | 60 +++++++++++--------------------- pyproject.toml | 9 +++-- tests/test_siren.py | 2 +- tox.ini | 2 +- 6 files changed, 35 insertions(+), 49 deletions(-) diff --git a/fastapi_hypermodel/hypermodel.py b/fastapi_hypermodel/hypermodel.py index f8a480d..ae04c28 100644 --- a/fastapi_hypermodel/hypermodel.py +++ b/fastapi_hypermodel/hypermodel.py @@ -56,7 +56,8 @@ def __schema_subclasses__( continue schema = subclass.model_json_schema() - deref_schema: Dict[str, Any] = jsonref.loads(json.dumps(schema)) # type: ignore + schema_dict = json.dumps(schema) + deref_schema: Dict[str, Any] = jsonref.loads(schema_dict) subclasses_schemas.append(deref_schema) @@ -103,7 +104,8 @@ def init_app(cls: Type[Self], app: Starlette) -> None: """ cls._app = app - def _parse_uri(self: Self, values: Any, uri_template: str) -> str: + @staticmethod + def _parse_uri(values: Any, uri_template: str) -> str: parameters: Dict[str, str] = {} for _, field, *_ in Formatter().parse(uri_template): diff --git a/fastapi_hypermodel/linkset.py b/fastapi_hypermodel/linkset.py index 68fdf79..ffb4921 100644 --- a/fastapi_hypermodel/linkset.py +++ b/fastapi_hypermodel/linkset.py @@ -1,11 +1,13 @@ from typing import ( Any, Dict, + List, Mapping, Optional, Sequence, Type, Union, + cast, ) from pydantic import ( @@ -66,6 +68,7 @@ def __call__( links: Dict[str, LinkType] = {} for key, hyperfields in self._mapping.items(): + hypermedia: Union[List[Any], Any] = [] if isinstance(hyperfields, Sequence): hypermedia = [hyperfield(app, values) for hyperfield in hyperfields] else: @@ -74,6 +77,6 @@ def __call__( if not hypermedia: continue - links[key] = hypermedia + links[key] = cast(LinkType, hypermedia) return LinkSetType(mapping=links) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index b674573..506cce9 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -5,7 +5,6 @@ Any, Callable, Dict, - Literal, Mapping, Sequence, TypeVar, @@ -132,32 +131,9 @@ def __call__( }) -FieldType = Literal[ - "hidden", - "text", - "search", - "tel", - "url", - "email", - "password", - "datetime", - "date", - "month", - "week", - "time", - "datetime-local", - "number", - "range", - "color", - "checkbox", - "radio", - "file", -] - - class SirenFieldType(SirenBase): name: str - type_: FieldType | None = Field(default=None, alias="type") + type_: str | None = Field(default=None, alias="type") value: Any | None = None @classmethod @@ -169,12 +145,15 @@ def from_field_info(cls: type[Self], name: str, field_info: FieldInfo) -> Self: }) @staticmethod - def parse_type(python_type: type[Any] | None) -> FieldType: + def parse_type(python_type: type[Any] | None) -> str: type_repr = repr(python_type) - if "str" in type_repr: + + text_types = ("str",) + if any(text_type in type_repr for text_type in text_types): return "text" - if "float" in type_repr or "int" in type_repr: + number_types = ("float", "int") + if any(number_type in type_repr for number_type in number_types): return "number" return "text" @@ -197,7 +176,7 @@ def mandatory(cls: type[Self], value: str | None) -> str: return value -class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): +class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): # pylint: disable=too-many-instance-attributes _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _templated: bool = PrivateAttr() @@ -273,8 +252,8 @@ def _compute_fields( if not body_field: return [] - annotation = body_field.field_info.annotation or {} - model_fields = annotation.model_fields if annotation else {} # type: ignore + annotation: Any = body_field.field_info.annotation or {} + model_fields: Any = annotation.model_fields if annotation else {} model_fields = cast(Dict[str, FieldInfo], model_fields) fields = list(starmap(SirenFieldType.from_field_info, model_fields.items())) @@ -364,12 +343,12 @@ def add_hypermodels_to_entities(self: Self) -> Self: ): continue - for field in value: - if isinstance(field, SirenLinkType): - entities.append(field) + for field_ in value: + if isinstance(field_, SirenLinkType): + entities.append(field_) continue - child = self.as_embedded(field, alias) + child = self.as_embedded(field_, alias) entities.append(child) delattr(self, name) @@ -413,11 +392,12 @@ def add_properties(self: Self) -> Self: @model_validator(mode="after") def add_links(self: Self) -> Self: + links_key = "links" validated_links: list[SirenLinkFor] = [] for name, value in self: alias = self.model_fields[name].alias or name - if alias != "links" or not value: + if alias != links_key or not value: continue links = cast(Sequence[SirenLinkFor], value) @@ -442,10 +422,11 @@ def validate_has_self_link(links: Sequence[SirenLinkFor]) -> None: @model_validator(mode="after") def add_actions(self: Self) -> Self: + actions_key = "actions" for name, value in self: - key = self.model_fields[name].alias or name + alias = self.model_fields[name].alias or name - if key != "actions" or not value: + if alias != actions_key or not value: continue properties = self.properties or {} @@ -491,7 +472,8 @@ def parse_uri(self: Self, uri_template: str) -> str: class SirenResponse(JSONResponse): media_type = "application/siren+json" - def _validate(self: Self, content: Any) -> None: + @staticmethod + def _validate(content: Any) -> None: jsonschema.validate(instance=content, schema=schema) def render(self: Self, content: Any) -> bytes: diff --git a/pyproject.toml b/pyproject.toml index dbf8830..422fa56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ exclude_also = [ [tool.mypy] strict = true +ignore_missing_imports = true ######################################### # Ruff @@ -66,10 +67,7 @@ ignore = [ "ANN401", # Ignore Anys "ISC001", # Disable for compatibility with ruff-format ] -extend-exclude = [ - "tests", - "*_schema.py" -] +extend-exclude = ["tests", "*_schema.py"] target-version = "py38" [tool.ruff.lint] @@ -187,6 +185,7 @@ disable = [ "too-many-arguments", "consider-using-assignment-expr", # Disabled until walrus operator is widely used "unused-variable", # Too many false positives + "too-many-lines", # Disable until refactor ] enable = [ "bad-inline-option", @@ -220,7 +219,7 @@ check-str-concat-over-line-jumps = true ignore-comments = true ignore-docstrings = true ignore-signatures = true -min-similarity-lines = 4 +min-similarity-lines = 7 [tool.pylint.variables] allow-global-unused-variables = false diff --git a/tests/test_siren.py b/tests/test_siren.py index f2e2217..fd7e64f 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -528,7 +528,7 @@ def test_siren_hypermodel_with_actions_outside_actions() -> None: class MockClassWithActions(SirenHyperModel): id_: str - model_action: SirenActionFor = SirenActionFor( + test_action: SirenActionFor = SirenActionFor( "mock_read_with_path_siren", {"id_": ""}, name="model" ) diff --git a/tox.ini b/tox.ini index b64c6a8..ae3aefa 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,7 @@ commands = poetry run coverage run --module pytest -k "not integration" tests/ [testenv:mypy] description = 'Execute static analysis with mypy (type annotations).' -commands = poetry run mypy fastapi_hypermodel/ +commands = poetry run mypy --install-types --non-interactive fastapi_hypermodel/ [testenv:ruff-check] description = 'Execute static analysis with ruff.' From a11ab273b2b346adc2f2831a8490c3edc70b81e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Tue, 23 Jan 2024 00:26:03 -0300 Subject: [PATCH 25/66] Remove unnecessary aliases for links and actions --- fastapi_hypermodel/siren.py | 8 ++++---- tests/test_siren.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 506cce9..4bf2d3e 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -316,10 +316,10 @@ class SirenEmbeddedType(SirenEntityType): class SirenHyperModel(HyperModel): - properties: dict[str, Any] | None = None - entities: Sequence[SirenEmbeddedType | SirenLinkType] | None = None - links_: Sequence[Self] | None = Field(default=None, alias="links") - actions_: Sequence[SirenActionType] | None = Field(default=None, alias="actions") + properties: dict[str, Any] = Field(default_factory=dict) + entities: Sequence[SirenEmbeddedType | SirenLinkType] = Field(default_factory=list) + links: Sequence[SirenLinkFor] = Field(default_factory=list) + actions: Sequence[SirenActionFor] = Field(default_factory=list) # This config is needed to use the Self in Embedded model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/tests/test_siren.py b/tests/test_siren.py index fd7e64f..c939c69 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -553,7 +553,7 @@ class MockClassWithActions(SirenHyperModel): ) mock = MockClassWithActions(id_="test") - assert not mock.actions_ + assert not mock.actions def test_siren_parse_uri() -> None: From 6981832ac38b6f947318e6cd6657ad74320bb161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Tue, 23 Jan 2024 20:03:26 -0300 Subject: [PATCH 26/66] Unify import statements --- fastapi_hypermodel/__init__.py | 2 ++ tests/integration/siren/conftest.py | 8 ++------ tests/integration/url_for/conftest.py | 8 ++------ tests/test_siren.py | 6 ++++-- tests/test_url_type.py | 3 +-- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index 8b03e7b..624e129 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -8,6 +8,7 @@ from .siren import ( SirenActionFor, SirenActionType, + SirenEmbeddedType, SirenFieldType, SirenHyperModel, SirenLinkFor, @@ -40,6 +41,7 @@ "SirenFieldType", "SirenLinkFor", "SirenLinkType", + "SirenEmbeddedType", "SirenHyperModel", "SirenResponse", "LinkSet", diff --git a/tests/integration/siren/conftest.py b/tests/integration/siren/conftest.py index 388f917..9441ae1 100644 --- a/tests/integration/siren/conftest.py +++ b/tests/integration/siren/conftest.py @@ -8,12 +8,8 @@ Person, app, ) -from examples.siren import ( - items as items_, -) -from examples.siren import ( - people as people_, -) +from examples.siren import items as items_ +from examples.siren import people as people_ from fastapi_hypermodel import SirenHyperModel diff --git a/tests/integration/url_for/conftest.py b/tests/integration/url_for/conftest.py index 34ef042..e957e9c 100644 --- a/tests/integration/url_for/conftest.py +++ b/tests/integration/url_for/conftest.py @@ -8,12 +8,8 @@ Person, app, ) -from examples.url_for import ( - items as items_, -) -from examples.url_for import ( - people as people_, -) +from examples.url_for import items as items_ +from examples.url_for import people as people_ from fastapi_hypermodel import HyperModel diff --git a/tests/test_siren.py b/tests/test_siren.py index c939c69..301a9c6 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -9,14 +9,16 @@ from fastapi_hypermodel import ( SirenActionFor, SirenActionType, + SirenEmbeddedType, SirenFieldType, SirenHyperModel, SirenLinkFor, SirenLinkType, SirenResponse, + UrlType, + get_siren_action, + get_siren_link, ) -from fastapi_hypermodel.siren import SirenEmbeddedType, get_siren_action, get_siren_link -from fastapi_hypermodel.url_type import UrlType SAMPLE_ENDPOINT = "/mock_read_with_path_siren/{id_}" diff --git a/tests/test_url_type.py b/tests/test_url_type.py index 1c77d77..5086df4 100644 --- a/tests/test_url_type.py +++ b/tests/test_url_type.py @@ -2,8 +2,7 @@ import pytest -from fastapi_hypermodel import HyperModel -from fastapi_hypermodel.url_type import UrlType +from fastapi_hypermodel import HyperModel, UrlType class MockClassWithURL(HyperModel): From 77d746be59f0598a71c635cbbcdb8e9bdc62f473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 27 Jan 2024 21:24:29 -0300 Subject: [PATCH 27/66] Add Python 3.8 compatibility --- fastapi_hypermodel/siren.py | 107 ++++++++++++++++++------------------ pyproject.toml | 1 + 2 files changed, 56 insertions(+), 52 deletions(-) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 4bf2d3e..1dc0938 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -8,6 +8,7 @@ Mapping, Sequence, TypeVar, + Union, cast, ) @@ -39,8 +40,8 @@ class SirenBase(BaseModel): - class_: Sequence[str] | None = Field(default=None, alias="class") - title: str | None = Field(default=None) + class_: Union[Sequence[str], None] = Field(default=None, alias="class") + title: Union[str, None] = Field(default=None) @model_serializer def serialize(self: Self) -> Mapping[str, Any]: @@ -50,11 +51,11 @@ def serialize(self: Self) -> Mapping[str, Any]: class SirenLinkType(SirenBase): rel: Sequence[str] = Field(default_factory=list) href: UrlType = Field(default=UrlType()) - type_: str | None = Field(default=None, alias="type") + type_: Union[str, None] = Field(default=None, alias="type") @field_validator("rel", "href") @classmethod - def mandatory(cls: type[Self], value: str | None) -> str: + def mandatory(cls: type[Self], value: Union[str, None]) -> str: if not value: error_message = "Field rel and href are mandatory" raise ValueError(error_message) @@ -66,24 +67,24 @@ class SirenLinkFor(SirenLinkType, AbstractHyperField[SirenLinkType]): _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _templated: bool = PrivateAttr() - _condition: Callable[[Mapping[str, Any]], bool] | None = PrivateAttr() + _condition: Union[Callable[[Mapping[str, Any]], bool], None] = PrivateAttr() # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal - _title: str | None = PrivateAttr() - _type: str | None = PrivateAttr() + _title: Union[str, None] = PrivateAttr() + _type: Union[str, None] = PrivateAttr() _rel: Sequence[str] = PrivateAttr() - _class: Sequence[str] | None = PrivateAttr() + _class: Union[Sequence[str], None] = PrivateAttr() def __init__( self: Self, - endpoint: HasName | str, - param_values: Mapping[str, str] | None = None, + endpoint: Union[HasName, str], + param_values: Union[Mapping[str, str], None] = None, templated: bool = False, - condition: Callable[[Mapping[str, Any]], bool] | None = None, - title: str | None = None, - type_: str | None = None, - rel: Sequence[str] | None = None, - class_: Sequence[str] | None = None, + condition: Union[Callable[[Mapping[str, Any]], bool], None] = None, + title: Union[str, None] = None, + type_: Union[str, None] = None, + rel: Union[Sequence[str], None] = None, + class_: Union[Sequence[str], None] = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -99,7 +100,7 @@ def __init__( self._class = class_ def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Route | str + self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] ) -> UrlType: if self._templated and isinstance(route, Route): return UrlType(route.path) @@ -108,8 +109,8 @@ def _get_uri_path( return UrlType(app.url_path_for(self._endpoint, **params)) def __call__( - self: Self, app: Starlette | None, values: Mapping[str, Any] - ) -> SirenLinkType | None: + self: Self, app: Union[Starlette, None], values: Mapping[str, Any] + ) -> Union[SirenLinkType, None]: if app is None: return None @@ -133,8 +134,8 @@ def __call__( class SirenFieldType(SirenBase): name: str - type_: str | None = Field(default=None, alias="type") - value: Any | None = None + type_: Union[str, None] = Field(default=None, alias="type") + value: Union[Any, None] = None @classmethod def from_field_info(cls: type[Self], name: str, field_info: FieldInfo) -> Self: @@ -145,7 +146,7 @@ def from_field_info(cls: type[Self], name: str, field_info: FieldInfo) -> Self: }) @staticmethod - def parse_type(python_type: type[Any] | None) -> str: + def parse_type(python_type: Union[type[Any], None]) -> str: type_repr = repr(python_type) text_types = ("str",) @@ -163,13 +164,13 @@ class SirenActionType(SirenBase): name: str = Field(default="") method: str = Field(default="GET") href: UrlType = Field(default=UrlType()) - type_: str | None = Field(default=None, alias="type") - fields: Sequence[SirenFieldType] | None = Field(default=None) + type_: Union[str, None] = Field(default=None, alias="type") + fields: Union[Sequence[SirenFieldType], None] = Field(default=None) templated: bool = Field(default=False) @field_validator("name", "href") @classmethod - def mandatory(cls: type[Self], value: str | None) -> str: + def mandatory(cls: type[Self], value: Union[str, None]) -> str: if not value: error_message = f"Field name and href are mandatory, {value}" raise ValueError(error_message) @@ -180,30 +181,30 @@ class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): # p _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _templated: bool = PrivateAttr() - _condition: Callable[[Mapping[str, Any]], bool] | None = PrivateAttr() + _condition: Union[Callable[[Mapping[str, Any]], bool], None] = PrivateAttr() _populate_fields: bool = PrivateAttr() # For details on the folllowing fields, check https://github.com/kevinswiber/siren - _class: Sequence[str] | None = PrivateAttr() - _title: str | None = PrivateAttr() - _name: str | None = PrivateAttr() - _method: str | None = PrivateAttr() - _type: str | None = PrivateAttr() - _fields: Sequence[SirenFieldType] | None = PrivateAttr() + _class: Union[Sequence[str], None] = PrivateAttr() + _title: Union[str, None] = PrivateAttr() + _name: Union[str, None] = PrivateAttr() + _method: Union[str, None] = PrivateAttr() + _type: Union[str, None] = PrivateAttr() + _fields: Union[Sequence[SirenFieldType], None] = PrivateAttr() def __init__( self: Self, - endpoint: HasName | str, - param_values: Mapping[str, str] | None = None, + endpoint: Union[HasName, str], + param_values: Union[Mapping[str, str], None] = None, templated: bool = False, - condition: Callable[[Mapping[str, Any]], bool] | None = None, + condition: Union[Callable[[Mapping[str, Any]], bool], None] = None, populate_fields: bool = True, - title: str | None = None, - type_: str | None = None, - class_: Sequence[str] | None = None, - fields: Sequence[SirenFieldType] | None = None, - method: str | None = None, - name: str | None = "", + title: Union[str, None] = None, + type_: Union[str, None] = None, + class_: Union[Sequence[str], None] = None, + fields: Union[Sequence[SirenFieldType], None] = None, + method: Union[str, None] = None, + name: Union[str, None] = "", **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -222,7 +223,7 @@ def __init__( self._class = class_ def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Route | str + self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] ) -> UrlType: if self._templated and isinstance(route, Route): return UrlType(route.path) @@ -260,8 +261,8 @@ def _compute_fields( return self._prepopulate_fields(fields, values) def __call__( - self: Self, app: Starlette | None, values: Mapping[str, Any] - ) -> SirenActionType | None: + self: Self, app: Union[Starlette, None], values: Mapping[str, Any] + ) -> Union[SirenActionType, None]: if app is None: return None @@ -295,10 +296,10 @@ def __call__( class SirenEntityType(SirenBase): - properties: Mapping[str, Any] | None = None - entities: Sequence[SirenEmbeddedType | SirenLinkType] | None = None - links: Sequence[SirenLinkType] | None = None - actions: Sequence[SirenActionType] | None = None + properties: Union[Mapping[str, Any], None] = None + entities: Union[Sequence[Union[SirenEmbeddedType, SirenLinkType]], None] = None + links: Union[Sequence[SirenLinkType], None] = None + actions: Union[Sequence[SirenActionType], None] = None class SirenEmbeddedType(SirenEntityType): @@ -317,7 +318,9 @@ class SirenEmbeddedType(SirenEntityType): class SirenHyperModel(HyperModel): properties: dict[str, Any] = Field(default_factory=dict) - entities: Sequence[SirenEmbeddedType | SirenLinkType] = Field(default_factory=list) + entities: Sequence[Union[SirenEmbeddedType, SirenLinkType]] = Field( + default_factory=list + ) links: Sequence[SirenLinkFor] = Field(default_factory=list) actions: Sequence[SirenActionFor] = Field(default_factory=list) @@ -326,14 +329,14 @@ class SirenHyperModel(HyperModel): @model_validator(mode="after") def add_hypermodels_to_entities(self: Self) -> Self: - entities: list[SirenEmbeddedType | SirenLinkType] = [] + entities: list[Union[SirenEmbeddedType, SirenLinkType]] = [] for name, field in self: alias = self.model_fields[name].alias or name if alias in SIREN_RESERVED_FIELDS: continue - value: Sequence[Any | Self] = ( + value: Sequence[Union[Any, Self]] = ( field if isinstance(field, Sequence) else [field] ) @@ -481,13 +484,13 @@ def render(self: Self, content: Any) -> bytes: return super().render(content) -def get_siren_link(response: Any, link_name: str) -> SirenLinkType | None: +def get_siren_link(response: Any, link_name: str) -> Union[SirenLinkType, None]: links = response.get("links", []) link = next((link for link in links if link_name in link.get("rel")), None) return SirenLinkType.model_validate(link) if link else None -def get_siren_action(response: Any, action_name: str) -> SirenActionType | None: +def get_siren_action(response: Any, action_name: str) -> Union[SirenActionType, None]: actions = response.get("actions", []) action = next( (action for action in actions if action_name in action.get("name")), None diff --git a/pyproject.toml b/pyproject.toml index 422fa56..2254d5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ ignore = [ "A003", # FIXME "ANN401", # Ignore Anys "ISC001", # Disable for compatibility with ruff-format + "UP007", # Not compatible with Python 3.8 ] extend-exclude = ["tests", "*_schema.py"] target-version = "py38" From 938c086d3cb9405ee76a475ee6f3069fa1d394f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 27 Jan 2024 21:30:01 -0300 Subject: [PATCH 28/66] Use Ruff verbose mode --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index ae3aefa..4a2af19 100644 --- a/tox.ini +++ b/tox.ini @@ -9,8 +9,8 @@ commands = [testenv:ruff-format] description = 'Check code style with Ruff' -commands = poetry run ruff format --check --force-exclude - poetry run ruff format --check tests +commands = poetry run ruff format --check --force-exclude --verbose + poetry run ruff format --check --verbose tests [testenv:pytest] description = 'Run Python tests with pytest test runner.' @@ -25,8 +25,8 @@ commands = poetry run mypy --install-types --non-interactive fastapi_hypermodel/ [testenv:ruff-check] description = 'Execute static analysis with ruff.' -commands = poetry run ruff check --force-exclude --exit-non-zero-on-fix - poetry run ruff check --exit-non-zero-on-fix tests +commands = poetry run ruff check --verbose --force-exclude --exit-non-zero-on-fix + poetry run ruff check --verbose --exit-non-zero-on-fix tests [testenv:pylint] description = 'Execute static analysis with pylint.' From 3757773282ba13900c64091436cea54e39c6eb1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 27 Jan 2024 21:37:10 -0300 Subject: [PATCH 29/66] Use Python 3.8 compatible types --- fastapi_hypermodel/siren.py | 24 +++++++++++++----------- pyproject.toml | 1 + 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py index 1dc0938..6cc787b 100644 --- a/fastapi_hypermodel/siren.py +++ b/fastapi_hypermodel/siren.py @@ -5,8 +5,10 @@ Any, Callable, Dict, + List, Mapping, Sequence, + Type, TypeVar, Union, cast, @@ -55,7 +57,7 @@ class SirenLinkType(SirenBase): @field_validator("rel", "href") @classmethod - def mandatory(cls: type[Self], value: Union[str, None]) -> str: + def mandatory(cls: Type[Self], value: Union[str, None]) -> str: if not value: error_message = "Field rel and href are mandatory" raise ValueError(error_message) @@ -138,7 +140,7 @@ class SirenFieldType(SirenBase): value: Union[Any, None] = None @classmethod - def from_field_info(cls: type[Self], name: str, field_info: FieldInfo) -> Self: + def from_field_info(cls: Type[Self], name: str, field_info: FieldInfo) -> Self: return cls.model_validate({ "name": name, "type": cls.parse_type(field_info.annotation), @@ -146,7 +148,7 @@ def from_field_info(cls: type[Self], name: str, field_info: FieldInfo) -> Self: }) @staticmethod - def parse_type(python_type: Union[type[Any], None]) -> str: + def parse_type(python_type: Union[Type[Any], None]) -> str: type_repr = repr(python_type) text_types = ("str",) @@ -170,7 +172,7 @@ class SirenActionType(SirenBase): @field_validator("name", "href") @classmethod - def mandatory(cls: type[Self], value: Union[str, None]) -> str: + def mandatory(cls: Type[Self], value: Union[str, None]) -> str: if not value: error_message = f"Field name and href are mandatory, {value}" raise ValueError(error_message) @@ -233,7 +235,7 @@ def _get_uri_path( def _prepopulate_fields( self: Self, fields: Sequence[SirenFieldType], values: Mapping[str, Any] - ) -> list[SirenFieldType]: + ) -> List[SirenFieldType]: if not self._populate_fields: return list(fields) @@ -244,7 +246,7 @@ def _prepopulate_fields( def _compute_fields( self: Self, route: Route, values: Mapping[str, Any] - ) -> list[SirenFieldType]: + ) -> List[SirenFieldType]: if not isinstance(route, APIRoute): # pragma: no cover route.body_field = "" # type: ignore route = cast(APIRoute, route) @@ -317,7 +319,7 @@ class SirenEmbeddedType(SirenEntityType): class SirenHyperModel(HyperModel): - properties: dict[str, Any] = Field(default_factory=dict) + properties: Dict[str, Any] = Field(default_factory=dict) entities: Sequence[Union[SirenEmbeddedType, SirenLinkType]] = Field( default_factory=list ) @@ -329,7 +331,7 @@ class SirenHyperModel(HyperModel): @model_validator(mode="after") def add_hypermodels_to_entities(self: Self) -> Self: - entities: list[Union[SirenEmbeddedType, SirenLinkType]] = [] + entities: List[Union[SirenEmbeddedType, SirenLinkType]] = [] for name, field in self: alias = self.model_fields[name].alias or name @@ -396,7 +398,7 @@ def add_properties(self: Self) -> Self: @model_validator(mode="after") def add_links(self: Self) -> Self: links_key = "links" - validated_links: list[SirenLinkFor] = [] + validated_links: List[SirenLinkFor] = [] for name, value in self: alias = self.model_fields[name].alias or name @@ -440,8 +442,8 @@ def add_actions(self: Self) -> Self: def _validate_factory( self: Self, elements: Sequence[T], properties: Mapping[str, str] - ) -> list[T]: - validated_elements: list[T] = [] + ) -> List[T]: + validated_elements: List[T] = [] for element_factory in elements: element = element_factory(self._app, properties) if not element: diff --git a/pyproject.toml b/pyproject.toml index 2254d5b..ed11b2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ ignore = [ "ANN401", # Ignore Anys "ISC001", # Disable for compatibility with ruff-format "UP007", # Not compatible with Python 3.8 + "UP006", # Not compatible with Python 3.8 ] extend-exclude = ["tests", "*_schema.py"] target-version = "py38" From a52dc2d6a0ddd0ffd570348de6c4faa8bb1bb175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 27 Jan 2024 21:43:18 -0300 Subject: [PATCH 30/66] Avoid fixing issues in ruff check --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 4a2af19..ea2a84a 100644 --- a/tox.ini +++ b/tox.ini @@ -9,8 +9,8 @@ commands = [testenv:ruff-format] description = 'Check code style with Ruff' -commands = poetry run ruff format --check --force-exclude --verbose - poetry run ruff format --check --verbose tests +commands = poetry run ruff format --check --force-exclude --verbose --no-fix + poetry run ruff format --check --verbose --no-fix tests [testenv:pytest] description = 'Run Python tests with pytest test runner.' @@ -25,8 +25,8 @@ commands = poetry run mypy --install-types --non-interactive fastapi_hypermodel/ [testenv:ruff-check] description = 'Execute static analysis with ruff.' -commands = poetry run ruff check --verbose --force-exclude --exit-non-zero-on-fix - poetry run ruff check --verbose --exit-non-zero-on-fix tests +commands = poetry run ruff check --verbose --no-fix --force-exclude --exit-non-zero-on-fix + poetry run ruff check --verbose --no-fix --exit-non-zero-on-fix tests [testenv:pylint] description = 'Execute static analysis with pylint.' From c6cdc83e94e1681662e149ef9960561281f224ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 27 Jan 2024 21:53:04 -0300 Subject: [PATCH 31/66] Fix RUF022 --- examples/hal/__init__.py | 2 +- examples/siren/__init__.py | 2 +- examples/url_for/__init__.py | 9 +-------- tox.ini | 8 +++----- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/examples/hal/__init__.py b/examples/hal/__init__.py index 04bb45b..21a0e02 100644 --- a/examples/hal/__init__.py +++ b/examples/hal/__init__.py @@ -1,4 +1,4 @@ from examples.hal.app import Item, ItemSummary, Person, app from examples.hal.data import curies, items, people -__all__ = ["ItemSummary", "Item", "Person", "app", "items", "people", "curies"] +__all__ = ['Item', 'ItemSummary', 'Person', 'app', 'curies', 'items', 'people'] diff --git a/examples/siren/__init__.py b/examples/siren/__init__.py index 291e6ab..e89ab7e 100644 --- a/examples/siren/__init__.py +++ b/examples/siren/__init__.py @@ -1,4 +1,4 @@ from examples.siren.app import Item, ItemSummary, Person, app from examples.siren.data import items, people -__all__ = ["ItemSummary", "Item", "Person", "app", "items", "people"] +__all__ = ['Item', 'ItemSummary', 'Person', 'app', 'items', 'people'] diff --git a/examples/url_for/__init__.py b/examples/url_for/__init__.py index 9a08b76..47caf90 100644 --- a/examples/url_for/__init__.py +++ b/examples/url_for/__init__.py @@ -1,11 +1,4 @@ from examples.url_for.app import Item, ItemSummary, Person, app from examples.url_for.data import items, people -__all__ = [ - "items", - "people", - "Person", - "ItemSummary", - "Item", - "app", -] +__all__ = ["Item", "ItemSummary", "Person", "app", "items", "people"] diff --git a/tox.ini b/tox.ini index ea2a84a..61e385a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] isolated_build = true -envlist = ruff-format, pytest, mypy, pylint, ruff-check, bandit +envlist = ruff-format, ruff-check, pytest, mypy, pylint, bandit [testenv] allowlist_externals = poetry @@ -9,8 +9,7 @@ commands = [testenv:ruff-format] description = 'Check code style with Ruff' -commands = poetry run ruff format --check --force-exclude --verbose --no-fix - poetry run ruff format --check --verbose --no-fix tests +commands = poetry run ruff format --check --force-exclude fastapi_hypermodel tests examples [testenv:pytest] description = 'Run Python tests with pytest test runner.' @@ -25,8 +24,7 @@ commands = poetry run mypy --install-types --non-interactive fastapi_hypermodel/ [testenv:ruff-check] description = 'Execute static analysis with ruff.' -commands = poetry run ruff check --verbose --no-fix --force-exclude --exit-non-zero-on-fix - poetry run ruff check --verbose --no-fix --exit-non-zero-on-fix tests +commands = poetry run ruff check --no-fix --force-exclude --exit-non-zero-on-fix fastapi_hypermodel tests examples [testenv:pylint] description = 'Execute static analysis with pylint.' From c24a73a6ccc945b40ef2f553edcd49b0a5e47edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 27 Jan 2024 21:57:46 -0300 Subject: [PATCH 32/66] Sort imports in __all__ --- examples/hal/__init__.py | 2 +- examples/siren/__init__.py | 2 +- fastapi_hypermodel/__init__.py | 28 ++++++++++++++-------------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/examples/hal/__init__.py b/examples/hal/__init__.py index 21a0e02..5401751 100644 --- a/examples/hal/__init__.py +++ b/examples/hal/__init__.py @@ -1,4 +1,4 @@ from examples.hal.app import Item, ItemSummary, Person, app from examples.hal.data import curies, items, people -__all__ = ['Item', 'ItemSummary', 'Person', 'app', 'curies', 'items', 'people'] +__all__ = ["Item", "ItemSummary", "Person", "app", "curies", "items", "people"] diff --git a/examples/siren/__init__.py b/examples/siren/__init__.py index e89ab7e..b36dff3 100644 --- a/examples/siren/__init__.py +++ b/examples/siren/__init__.py @@ -1,4 +1,4 @@ from examples.siren.app import Item, ItemSummary, Person, app from examples.siren.data import items, people -__all__ = ['Item', 'ItemSummary', 'Person', 'app', 'items', 'people'] +__all__ = ["Item", "ItemSummary", "Person", "app", "items", "people"] diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index 624e129..33a7a21 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -28,31 +28,31 @@ ) __all__ = [ - "InvalidAttribute", - "HasName", - "HyperModel", - "UrlFor", + "AbstractHyperField", "HALFor", "HALForType", "HALResponse", "HalHyperModel", - "SirenActionType", + "HasName", + "HyperModel", + "InvalidAttribute", + "LinkSet", + "LinkSetType", "SirenActionFor", + "SirenActionType", + "SirenEmbeddedType", "SirenFieldType", + "SirenHyperModel", "SirenLinkFor", "SirenLinkType", - "SirenEmbeddedType", - "SirenHyperModel", "SirenResponse", - "LinkSet", - "LinkSetType", + "URL_TYPE_SCHEMA", + "UrlFor", "UrlType", - "resolve_param_values", - "AbstractHyperField", + "extract_value_by_name", "get_hal_link_href", + "get_route_from_app", "get_siren_action", "get_siren_link", - "extract_value_by_name", - "get_route_from_app", - "URL_TYPE_SCHEMA", + "resolve_param_values", ] From 0bb8b8fa48c1f7a7424ab8a57540e676d33d302e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 27 Jan 2024 22:05:34 -0300 Subject: [PATCH 33/66] Fix RUF022 check --- fastapi_hypermodel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index 33a7a21..8cdc3d4 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -28,6 +28,7 @@ ) __all__ = [ + "URL_TYPE_SCHEMA", "AbstractHyperField", "HALFor", "HALForType", @@ -46,7 +47,6 @@ "SirenLinkFor", "SirenLinkType", "SirenResponse", - "URL_TYPE_SCHEMA", "UrlFor", "UrlType", "extract_value_by_name", From 7c1d95db007ddd3ec656a1006d745525d2aba921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 27 Jan 2024 22:07:40 -0300 Subject: [PATCH 34/66] Add poetry.lock to git to avoid version mismatches --- .gitignore | 3 - poetry.lock | 1896 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1896 insertions(+), 3 deletions(-) create mode 100644 poetry.lock diff --git a/.gitignore b/.gitignore index 0bb6113..c2e68ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# Ignore lockfile. See https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -poetry.lock - # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..eb1c7ae --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1896 @@ +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} + +[[package]] +name = "anyio" +version = "4.2.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee"}, + {file = "anyio-4.2.0.tar.gz", hash = "sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + +[[package]] +name = "astroid" +version = "3.0.2" +description = "An abstract syntax tree for Python with inference support." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "astroid-3.0.2-py3-none-any.whl", hash = "sha256:d6e62862355f60e716164082d6b4b041d38e2a8cf1c7cd953ded5108bac8ff5c"}, + {file = "astroid-3.0.2.tar.gz", hash = "sha256:4a61cf0a59097c7bb52689b0fd63717cd2a8a14dc9f1eee97b82d814881c8c91"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} + +[[package]] +name = "attrs" +version = "23.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] + +[[package]] +name = "babel" +version = "2.14.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, + {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, +] + +[package.dependencies] +pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} + +[package.extras] +dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] + +[[package]] +name = "bandit" +version = "1.7.7" +description = "Security oriented static analyser for python code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "bandit-1.7.7-py3-none-any.whl", hash = "sha256:17e60786a7ea3c9ec84569fd5aee09936d116cb0cb43151023258340dbffb7ed"}, + {file = "bandit-1.7.7.tar.gz", hash = "sha256:527906bec6088cb499aae31bc962864b4e77569e9d529ee51df3a93b4b8ab28a"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +PyYAML = ">=5.3.1" +rich = "*" +stevedore = ">=1.20.0" + +[package.extras] +baseline = ["GitPython (>=3.1.30)"] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] +toml = ["tomli (>=1.1.0)"] +yaml = ["PyYAML"] + +[[package]] +name = "cachetools" +version = "5.3.2" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.3.2-py3-none-any.whl", hash = "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1"}, + {file = "cachetools-5.3.2.tar.gz", hash = "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2"}, +] + +[[package]] +name = "certifi" +version = "2023.11.17" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, + {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, +] + +[[package]] +name = "chardet" +version = "5.2.0" +description = "Universal encoding detector for Python 3" +optional = false +python-versions = ">=3.7" +files = [ + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.4.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7"}, + {file = "coverage-7.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b"}, + {file = "coverage-7.4.1-cp310-cp310-win32.whl", hash = "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016"}, + {file = "coverage-7.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018"}, + {file = "coverage-7.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295"}, + {file = "coverage-7.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6"}, + {file = "coverage-7.4.1-cp311-cp311-win32.whl", hash = "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5"}, + {file = "coverage-7.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968"}, + {file = "coverage-7.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581"}, + {file = "coverage-7.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc"}, + {file = "coverage-7.4.1-cp312-cp312-win32.whl", hash = "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74"}, + {file = "coverage-7.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448"}, + {file = "coverage-7.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218"}, + {file = "coverage-7.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad"}, + {file = "coverage-7.4.1-cp38-cp38-win32.whl", hash = "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042"}, + {file = "coverage-7.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d"}, + {file = "coverage-7.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54"}, + {file = "coverage-7.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35"}, + {file = "coverage-7.4.1-cp39-cp39-win32.whl", hash = "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c"}, + {file = "coverage-7.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a"}, + {file = "coverage-7.4.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166"}, + {file = "coverage-7.4.1.tar.gz", hash = "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "dill" +version = "0.3.8" +description = "serialize all of Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, + {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, +] + +[package.extras] +graph = ["objgraph (>=1.7.2)"] +profile = ["gprof2dot (>=2022.7.29)"] + +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "fastapi" +version = "0.109.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi-0.109.0-py3-none-any.whl", hash = "sha256:8c77515984cd8e8cfeb58364f8cc7a28f0692088475e2614f7bf03275eba9093"}, + {file = "fastapi-0.109.0.tar.gz", hash = "sha256:b978095b9ee01a5cf49b19f4bc1ac9b8ca83aa076e770ef8fd9af09a2b88d191"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.35.0,<0.36.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "filelock" +version = "3.13.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, + {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +optional = false +python-versions = "*" +files = [ + {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, + {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.2" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"}, + {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.23.0)"] + +[[package]] +name = "httpx" +version = "0.26.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"}, + {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "idna" +version = "3.6" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, +] + +[[package]] +name = "importlib-metadata" +version = "7.0.1" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, + {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] + +[[package]] +name = "importlib-resources" +version = "6.1.1" +description = "Read resources from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_resources-6.1.1-py3-none-any.whl", hash = "sha256:e8bf90d8213b486f428c9c39714b920041cb02c184686a3dee24905aaa8105d6"}, + {file = "importlib_resources-6.1.1.tar.gz", hash = "sha256:3893a00122eafde6894c59914446a512f728a0c1a45f9bb9b63721b6bacf0b4a"}, +] + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff", "zipp (>=3.17)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + +[[package]] +name = "jinja2" +version = "3.1.3" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jsonref" +version = "1.1.0" +description = "jsonref is a library for automatic dereferencing of JSON Reference objects for Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9"}, + {file = "jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552"}, +] + +[[package]] +name = "jsonschema" +version = "4.21.1" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema-4.21.1-py3-none-any.whl", hash = "sha256:7996507afae316306f9e2290407761157c6f78002dcf7419acb99822143d1c6f"}, + {file = "jsonschema-4.21.1.tar.gz", hash = "sha256:85727c00279f5fa6bedbe6238d2aa6403bedd8b4864ab11207d07df3cc1b2ee5"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} +jsonschema-specifications = ">=2023.03.6" +pkgutil-resolve-name = {version = ">=1.3.10", markers = "python_version < \"3.9\""} +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=1.11)"] + +[[package]] +name = "jsonschema-specifications" +version = "2023.12.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, + {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, +] + +[package.dependencies] +importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} +referencing = ">=0.31.0" + +[[package]] +name = "markdown" +version = "3.5.2" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Markdown-3.5.2-py3-none-any.whl", hash = "sha256:d43323865d89fc0cb9b20c75fc8ad313af307cc087e84b657d9eec768eddeadd"}, + {file = "Markdown-3.5.2.tar.gz", hash = "sha256:e1ac7b3dc550ee80e602e71c1d168002f062e49f1b11e26a36264dafd4df2ef8"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "2.1.4" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de8153a7aae3835484ac168a9a9bdaa0c5eee4e0bc595503c95d53b942879c84"}, + {file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e888ff76ceb39601c59e219f281466c6d7e66bd375b4ec1ce83bcdc68306796b"}, + {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0b838c37ba596fcbfca71651a104a611543077156cb0a26fe0c475e1f152ee8"}, + {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac1ebf6983148b45b5fa48593950f90ed6d1d26300604f321c74a9ca1609f8e"}, + {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fbad3d346df8f9d72622ac71b69565e621ada2ce6572f37c2eae8dacd60385d"}, + {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5291d98cd3ad9a562883468c690a2a238c4a6388ab3bd155b0c75dd55ece858"}, + {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a7cc49ef48a3c7a0005a949f3c04f8baa5409d3f663a1b36f0eba9bfe2a0396e"}, + {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b83041cda633871572f0d3c41dddd5582ad7d22f65a72eacd8d3d6d00291df26"}, + {file = "MarkupSafe-2.1.4-cp310-cp310-win32.whl", hash = "sha256:0c26f67b3fe27302d3a412b85ef696792c4a2386293c53ba683a89562f9399b0"}, + {file = "MarkupSafe-2.1.4-cp310-cp310-win_amd64.whl", hash = "sha256:a76055d5cb1c23485d7ddae533229039b850db711c554a12ea64a0fd8a0129e2"}, + {file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e9e3c4020aa2dc62d5dd6743a69e399ce3de58320522948af6140ac959ab863"}, + {file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0042d6a9880b38e1dd9ff83146cc3c9c18a059b9360ceae207805567aacccc69"}, + {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d03fea4c4e9fd0ad75dc2e7e2b6757b80c152c032ea1d1de487461d8140efc"}, + {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ab3a886a237f6e9c9f4f7d272067e712cdb4efa774bef494dccad08f39d8ae6"}, + {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abf5ebbec056817057bfafc0445916bb688a255a5146f900445d081db08cbabb"}, + {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e1a0d1924a5013d4f294087e00024ad25668234569289650929ab871231668e7"}, + {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e7902211afd0af05fbadcc9a312e4cf10f27b779cf1323e78d52377ae4b72bea"}, + {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c669391319973e49a7c6230c218a1e3044710bc1ce4c8e6eb71f7e6d43a2c131"}, + {file = "MarkupSafe-2.1.4-cp311-cp311-win32.whl", hash = "sha256:31f57d64c336b8ccb1966d156932f3daa4fee74176b0fdc48ef580be774aae74"}, + {file = "MarkupSafe-2.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:54a7e1380dfece8847c71bf7e33da5d084e9b889c75eca19100ef98027bd9f56"}, + {file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a76cd37d229fc385738bd1ce4cba2a121cf26b53864c1772694ad0ad348e509e"}, + {file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:987d13fe1d23e12a66ca2073b8d2e2a75cec2ecb8eab43ff5624ba0ad42764bc"}, + {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5244324676254697fe5c181fc762284e2c5fceeb1c4e3e7f6aca2b6f107e60dc"}, + {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78bc995e004681246e85e28e068111a4c3f35f34e6c62da1471e844ee1446250"}, + {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4d176cfdfde84f732c4a53109b293d05883e952bbba68b857ae446fa3119b4f"}, + {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f9917691f410a2e0897d1ef99619fd3f7dd503647c8ff2475bf90c3cf222ad74"}, + {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f06e5a9e99b7df44640767842f414ed5d7bedaaa78cd817ce04bbd6fd86e2dd6"}, + {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:396549cea79e8ca4ba65525470d534e8a41070e6b3500ce2414921099cb73e8d"}, + {file = "MarkupSafe-2.1.4-cp312-cp312-win32.whl", hash = "sha256:f6be2d708a9d0e9b0054856f07ac7070fbe1754be40ca8525d5adccdbda8f475"}, + {file = "MarkupSafe-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:5045e892cfdaecc5b4c01822f353cf2c8feb88a6ec1c0adef2a2e705eef0f656"}, + {file = "MarkupSafe-2.1.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a07f40ef8f0fbc5ef1000d0c78771f4d5ca03b4953fc162749772916b298fc4"}, + {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d18b66fe626ac412d96c2ab536306c736c66cf2a31c243a45025156cc190dc8a"}, + {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:698e84142f3f884114ea8cf83e7a67ca8f4ace8454e78fe960646c6c91c63bfa"}, + {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a3b78a5af63ec10d8604180380c13dcd870aba7928c1fe04e881d5c792dc4e"}, + {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:15866d7f2dc60cfdde12ebb4e75e41be862348b4728300c36cdf405e258415ec"}, + {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6aa5e2e7fc9bc042ae82d8b79d795b9a62bd8f15ba1e7594e3db243f158b5565"}, + {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:54635102ba3cf5da26eb6f96c4b8c53af8a9c0d97b64bdcb592596a6255d8518"}, + {file = "MarkupSafe-2.1.4-cp37-cp37m-win32.whl", hash = "sha256:3583a3a3ab7958e354dc1d25be74aee6228938312ee875a22330c4dc2e41beb0"}, + {file = "MarkupSafe-2.1.4-cp37-cp37m-win_amd64.whl", hash = "sha256:d6e427c7378c7f1b2bef6a344c925b8b63623d3321c09a237b7cc0e77dd98ceb"}, + {file = "MarkupSafe-2.1.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:bf1196dcc239e608605b716e7b166eb5faf4bc192f8a44b81e85251e62584bd2"}, + {file = "MarkupSafe-2.1.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4df98d4a9cd6a88d6a585852f56f2155c9cdb6aec78361a19f938810aa020954"}, + {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b835aba863195269ea358cecc21b400276747cc977492319fd7682b8cd2c253d"}, + {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23984d1bdae01bee794267424af55eef4dfc038dc5d1272860669b2aa025c9e3"}, + {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c98c33ffe20e9a489145d97070a435ea0679fddaabcafe19982fe9c971987d5"}, + {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9896fca4a8eb246defc8b2a7ac77ef7553b638e04fbf170bff78a40fa8a91474"}, + {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b0fe73bac2fed83839dbdbe6da84ae2a31c11cfc1c777a40dbd8ac8a6ed1560f"}, + {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c7556bafeaa0a50e2fe7dc86e0382dea349ebcad8f010d5a7dc6ba568eaaa789"}, + {file = "MarkupSafe-2.1.4-cp38-cp38-win32.whl", hash = "sha256:fc1a75aa8f11b87910ffd98de62b29d6520b6d6e8a3de69a70ca34dea85d2a8a"}, + {file = "MarkupSafe-2.1.4-cp38-cp38-win_amd64.whl", hash = "sha256:3a66c36a3864df95e4f62f9167c734b3b1192cb0851b43d7cc08040c074c6279"}, + {file = "MarkupSafe-2.1.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:765f036a3d00395a326df2835d8f86b637dbaf9832f90f5d196c3b8a7a5080cb"}, + {file = "MarkupSafe-2.1.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:21e7af8091007bf4bebf4521184f4880a6acab8df0df52ef9e513d8e5db23411"}, + {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5c31fe855c77cad679b302aabc42d724ed87c043b1432d457f4976add1c2c3e"}, + {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7653fa39578957bc42e5ebc15cf4361d9e0ee4b702d7d5ec96cdac860953c5b4"}, + {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47bb5f0142b8b64ed1399b6b60f700a580335c8e1c57f2f15587bd072012decc"}, + {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:fe8512ed897d5daf089e5bd010c3dc03bb1bdae00b35588c49b98268d4a01e00"}, + {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:36d7626a8cca4d34216875aee5a1d3d654bb3dac201c1c003d182283e3205949"}, + {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b6f14a9cd50c3cb100eb94b3273131c80d102e19bb20253ac7bd7336118a673a"}, + {file = "MarkupSafe-2.1.4-cp39-cp39-win32.whl", hash = "sha256:c8f253a84dbd2c63c19590fa86a032ef3d8cc18923b8049d91bcdeeb2581fbf6"}, + {file = "MarkupSafe-2.1.4-cp39-cp39-win_amd64.whl", hash = "sha256:8b570a1537367b52396e53325769608f2a687ec9a4363647af1cded8928af959"}, + {file = "MarkupSafe-2.1.4.tar.gz", hash = "sha256:3aae9af4cac263007fd6309c64c6ab4506dd2b79382d9d19a1994f9240b8db4f"}, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +optional = false +python-versions = ">=3.6" +files = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] + +[[package]] +name = "mkdocs" +version = "1.5.3" +description = "Project documentation with Markdown." +optional = false +python-versions = ">=3.7" +files = [ + {file = "mkdocs-1.5.3-py3-none-any.whl", hash = "sha256:3b3a78e736b31158d64dbb2f8ba29bd46a379d0c6e324c2246c3bc3d2189cfc1"}, + {file = "mkdocs-1.5.3.tar.gz", hash = "sha256:eb7c99214dcb945313ba30426c2451b735992c73c2e10838f76d09e39ff4d0e2"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} +jinja2 = ">=2.11.1" +markdown = ">=3.2.1" +markupsafe = ">=2.0.1" +mergedeep = ">=1.3.4" +packaging = ">=20.5" +pathspec = ">=0.11.1" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.3)", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pathspec (==0.11.1)", "platformdirs (==2.2.0)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-material" +version = "9.5.5" +description = "Documentation that simply works" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_material-9.5.5-py3-none-any.whl", hash = "sha256:ac50b2431a79a3b160fdefbba37c9132485f1a69166aba115ad49fafdbbbc5df"}, + {file = "mkdocs_material-9.5.5.tar.gz", hash = "sha256:4480d9580faf42fed0123d0465502bfc1c0c239ecc9c4d66159cf0459ea1b4ae"}, +] + +[package.dependencies] +babel = ">=2.10,<3.0" +colorama = ">=0.4,<1.0" +jinja2 = ">=3.0,<4.0" +markdown = ">=3.2,<4.0" +mkdocs = ">=1.5.3,<1.6.0" +mkdocs-material-extensions = ">=1.3,<2.0" +paginate = ">=0.5,<1.0" +pygments = ">=2.16,<3.0" +pymdown-extensions = ">=10.2,<11.0" +regex = ">=2022.4" +requests = ">=2.26,<3.0" + +[package.extras] +git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2,<2.0)"] +imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"] +recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +description = "Extension pack for Python Markdown and MkDocs Material." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, + {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, +] + +[[package]] +name = "mypy" +version = "1.8.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, + {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, + {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, + {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, + {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, + {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, + {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, + {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, + {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, + {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, + {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, + {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, + {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, + {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, + {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, + {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, + {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, + {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, + {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "paginate" +version = "0.5.6" +description = "Divides large result sets into pages for easier browsing" +optional = false +python-versions = "*" +files = [ + {file = "paginate-0.5.6.tar.gz", hash = "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pbr" +version = "6.0.0" +description = "Python Build Reasonableness" +optional = false +python-versions = ">=2.6" +files = [ + {file = "pbr-6.0.0-py2.py3-none-any.whl", hash = "sha256:4a7317d5e3b17a3dccb6a8cfe67dab65b20551404c52c8ed41279fa4f0cb4cda"}, + {file = "pbr-6.0.0.tar.gz", hash = "sha256:d1377122a5a00e2f940ee482999518efe16d745d423a670c27773dfbc3c9a7d9"}, +] + +[[package]] +name = "pkgutil-resolve-name" +version = "1.3.10" +description = "Resolve a name to an object." +optional = false +python-versions = ">=3.6" +files = [ + {file = "pkgutil_resolve_name-1.3.10-py3-none-any.whl", hash = "sha256:ca27cc078d25c5ad71a9de0a7a330146c4e014c2462d9af19c6b828280649c5e"}, + {file = "pkgutil_resolve_name-1.3.10.tar.gz", hash = "sha256:357d6c9e6a755653cfd78893817c0853af365dd51ec97f3d358a819373bbd174"}, +] + +[[package]] +name = "platformdirs" +version = "4.1.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, + {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + +[[package]] +name = "pluggy" +version = "1.4.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pydantic" +version = "2.5.3" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic-2.5.3-py3-none-any.whl", hash = "sha256:d0caf5954bee831b6bfe7e338c32b9e30c85dfe080c843680783ac2b631673b4"}, + {file = "pydantic-2.5.3.tar.gz", hash = "sha256:b3ef57c62535b0941697cce638c08900d87fcb67e29cfa99e8a68f747f393f7a"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.14.6" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.14.6" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic_core-2.14.6-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:72f9a942d739f09cd42fffe5dc759928217649f070056f03c70df14f5770acf9"}, + {file = "pydantic_core-2.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6a31d98c0d69776c2576dda4b77b8e0c69ad08e8b539c25c7d0ca0dc19a50d6c"}, + {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5aa90562bc079c6c290f0512b21768967f9968e4cfea84ea4ff5af5d917016e4"}, + {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:370ffecb5316ed23b667d99ce4debe53ea664b99cc37bfa2af47bc769056d534"}, + {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f85f3843bdb1fe80e8c206fe6eed7a1caeae897e496542cee499c374a85c6e08"}, + {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9862bf828112e19685b76ca499b379338fd4c5c269d897e218b2ae8fcb80139d"}, + {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:036137b5ad0cb0004c75b579445a1efccd072387a36c7f217bb8efd1afbe5245"}, + {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92879bce89f91f4b2416eba4429c7b5ca22c45ef4a499c39f0c5c69257522c7c"}, + {file = "pydantic_core-2.14.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0c08de15d50fa190d577e8591f0329a643eeaed696d7771760295998aca6bc66"}, + {file = "pydantic_core-2.14.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:36099c69f6b14fc2c49d7996cbf4f87ec4f0e66d1c74aa05228583225a07b590"}, + {file = "pydantic_core-2.14.6-cp310-none-win32.whl", hash = "sha256:7be719e4d2ae6c314f72844ba9d69e38dff342bc360379f7c8537c48e23034b7"}, + {file = "pydantic_core-2.14.6-cp310-none-win_amd64.whl", hash = "sha256:36fa402dcdc8ea7f1b0ddcf0df4254cc6b2e08f8cd80e7010d4c4ae6e86b2a87"}, + {file = "pydantic_core-2.14.6-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:dea7fcd62915fb150cdc373212141a30037e11b761fbced340e9db3379b892d4"}, + {file = "pydantic_core-2.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ffff855100bc066ff2cd3aa4a60bc9534661816b110f0243e59503ec2df38421"}, + {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b027c86c66b8627eb90e57aee1f526df77dc6d8b354ec498be9a757d513b92b"}, + {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00b1087dabcee0b0ffd104f9f53d7d3eaddfaa314cdd6726143af6bc713aa27e"}, + {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75ec284328b60a4e91010c1acade0c30584f28a1f345bc8f72fe8b9e46ec6a96"}, + {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e1f4744eea1501404b20b0ac059ff7e3f96a97d3e3f48ce27a139e053bb370b"}, + {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2602177668f89b38b9f84b7b3435d0a72511ddef45dc14446811759b82235a1"}, + {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c8edaea3089bf908dd27da8f5d9e395c5b4dc092dbcce9b65e7156099b4b937"}, + {file = "pydantic_core-2.14.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:478e9e7b360dfec451daafe286998d4a1eeaecf6d69c427b834ae771cad4b622"}, + {file = "pydantic_core-2.14.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b6ca36c12a5120bad343eef193cc0122928c5c7466121da7c20f41160ba00ba2"}, + {file = "pydantic_core-2.14.6-cp311-none-win32.whl", hash = "sha256:2b8719037e570639e6b665a4050add43134d80b687288ba3ade18b22bbb29dd2"}, + {file = "pydantic_core-2.14.6-cp311-none-win_amd64.whl", hash = "sha256:78ee52ecc088c61cce32b2d30a826f929e1708f7b9247dc3b921aec367dc1b23"}, + {file = "pydantic_core-2.14.6-cp311-none-win_arm64.whl", hash = "sha256:a19b794f8fe6569472ff77602437ec4430f9b2b9ec7a1105cfd2232f9ba355e6"}, + {file = "pydantic_core-2.14.6-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:667aa2eac9cd0700af1ddb38b7b1ef246d8cf94c85637cbb03d7757ca4c3fdec"}, + {file = "pydantic_core-2.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdee837710ef6b56ebd20245b83799fce40b265b3b406e51e8ccc5b85b9099b7"}, + {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c5bcf3414367e29f83fd66f7de64509a8fd2368b1edf4351e862910727d3e51"}, + {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a92ae76f75d1915806b77cf459811e772d8f71fd1e4339c99750f0e7f6324f"}, + {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a983cca5ed1dd9a35e9e42ebf9f278d344603bfcb174ff99a5815f953925140a"}, + {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb92f9061657287eded380d7dc455bbf115430b3aa4741bdc662d02977e7d0af"}, + {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4ace1e220b078c8e48e82c081e35002038657e4b37d403ce940fa679e57113b"}, + {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef633add81832f4b56d3b4c9408b43d530dfca29e68fb1b797dcb861a2c734cd"}, + {file = "pydantic_core-2.14.6-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7e90d6cc4aad2cc1f5e16ed56e46cebf4877c62403a311af20459c15da76fd91"}, + {file = "pydantic_core-2.14.6-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e8a5ac97ea521d7bde7621d86c30e86b798cdecd985723c4ed737a2aa9e77d0c"}, + {file = "pydantic_core-2.14.6-cp312-none-win32.whl", hash = "sha256:f27207e8ca3e5e021e2402ba942e5b4c629718e665c81b8b306f3c8b1ddbb786"}, + {file = "pydantic_core-2.14.6-cp312-none-win_amd64.whl", hash = "sha256:b3e5fe4538001bb82e2295b8d2a39356a84694c97cb73a566dc36328b9f83b40"}, + {file = "pydantic_core-2.14.6-cp312-none-win_arm64.whl", hash = "sha256:64634ccf9d671c6be242a664a33c4acf12882670b09b3f163cd00a24cffbd74e"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:24368e31be2c88bd69340fbfe741b405302993242ccb476c5c3ff48aeee1afe0"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:e33b0834f1cf779aa839975f9d8755a7c2420510c0fa1e9fa0497de77cd35d2c"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6af4b3f52cc65f8a0bc8b1cd9676f8c21ef3e9132f21fed250f6958bd7223bed"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15687d7d7f40333bd8266f3814c591c2e2cd263fa2116e314f60d82086e353a"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:095b707bb287bfd534044166ab767bec70a9bba3175dcdc3371782175c14e43c"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94fc0e6621e07d1e91c44e016cc0b189b48db053061cc22d6298a611de8071bb"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce830e480f6774608dedfd4a90c42aac4a7af0a711f1b52f807130c2e434c06"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a306cdd2ad3a7d795d8e617a58c3a2ed0f76c8496fb7621b6cd514eb1532cae8"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2f5fa187bde8524b1e37ba894db13aadd64faa884657473b03a019f625cee9a8"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:438027a975cc213a47c5d70672e0d29776082155cfae540c4e225716586be75e"}, + {file = "pydantic_core-2.14.6-cp37-none-win32.whl", hash = "sha256:f96ae96a060a8072ceff4cfde89d261837b4294a4f28b84a28765470d502ccc6"}, + {file = "pydantic_core-2.14.6-cp37-none-win_amd64.whl", hash = "sha256:e646c0e282e960345314f42f2cea5e0b5f56938c093541ea6dbf11aec2862391"}, + {file = "pydantic_core-2.14.6-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:db453f2da3f59a348f514cfbfeb042393b68720787bbef2b4c6068ea362c8149"}, + {file = "pydantic_core-2.14.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3860c62057acd95cc84044e758e47b18dcd8871a328ebc8ccdefd18b0d26a21b"}, + {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36026d8f99c58d7044413e1b819a67ca0e0b8ebe0f25e775e6c3d1fabb3c38fb"}, + {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ed1af8692bd8d2a29d702f1a2e6065416d76897d726e45a1775b1444f5928a7"}, + {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:314ccc4264ce7d854941231cf71b592e30d8d368a71e50197c905874feacc8a8"}, + {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:982487f8931067a32e72d40ab6b47b1628a9c5d344be7f1a4e668fb462d2da42"}, + {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dbe357bc4ddda078f79d2a36fc1dd0494a7f2fad83a0a684465b6f24b46fe80"}, + {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f6ffc6701a0eb28648c845f4945a194dc7ab3c651f535b81793251e1185ac3d"}, + {file = "pydantic_core-2.14.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7f5025db12fc6de7bc1104d826d5aee1d172f9ba6ca936bf6474c2148ac336c1"}, + {file = "pydantic_core-2.14.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:dab03ed811ed1c71d700ed08bde8431cf429bbe59e423394f0f4055f1ca0ea60"}, + {file = "pydantic_core-2.14.6-cp38-none-win32.whl", hash = "sha256:dfcbebdb3c4b6f739a91769aea5ed615023f3c88cb70df812849aef634c25fbe"}, + {file = "pydantic_core-2.14.6-cp38-none-win_amd64.whl", hash = "sha256:99b14dbea2fdb563d8b5a57c9badfcd72083f6006caf8e126b491519c7d64ca8"}, + {file = "pydantic_core-2.14.6-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:4ce8299b481bcb68e5c82002b96e411796b844d72b3e92a3fbedfe8e19813eab"}, + {file = "pydantic_core-2.14.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b9a9d92f10772d2a181b5ca339dee066ab7d1c9a34ae2421b2a52556e719756f"}, + {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd9e98b408384989ea4ab60206b8e100d8687da18b5c813c11e92fd8212a98e0"}, + {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f86f1f318e56f5cbb282fe61eb84767aee743ebe32c7c0834690ebea50c0a6b"}, + {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86ce5fcfc3accf3a07a729779d0b86c5d0309a4764c897d86c11089be61da160"}, + {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dcf1978be02153c6a31692d4fbcc2a3f1db9da36039ead23173bc256ee3b91b"}, + {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eedf97be7bc3dbc8addcef4142f4b4164066df0c6f36397ae4aaed3eb187d8ab"}, + {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d5f916acf8afbcab6bacbb376ba7dc61f845367901ecd5e328fc4d4aef2fcab0"}, + {file = "pydantic_core-2.14.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8a14c192c1d724c3acbfb3f10a958c55a2638391319ce8078cb36c02283959b9"}, + {file = "pydantic_core-2.14.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0348b1dc6b76041516e8a854ff95b21c55f5a411c3297d2ca52f5528e49d8411"}, + {file = "pydantic_core-2.14.6-cp39-none-win32.whl", hash = "sha256:de2a0645a923ba57c5527497daf8ec5df69c6eadf869e9cd46e86349146e5975"}, + {file = "pydantic_core-2.14.6-cp39-none-win_amd64.whl", hash = "sha256:aca48506a9c20f68ee61c87f2008f81f8ee99f8d7f0104bff3c47e2d148f89d9"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:d5c28525c19f5bb1e09511669bb57353d22b94cf8b65f3a8d141c389a55dec95"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:78d0768ee59baa3de0f4adac9e3748b4b1fffc52143caebddfd5ea2961595277"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b93785eadaef932e4fe9c6e12ba67beb1b3f1e5495631419c784ab87e975670"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a874f21f87c485310944b2b2734cd6d318765bcbb7515eead33af9641816506e"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89f4477d915ea43b4ceea6756f63f0288941b6443a2b28c69004fe07fde0d0d"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:172de779e2a153d36ee690dbc49c6db568d7b33b18dc56b69a7514aecbcf380d"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:dfcebb950aa7e667ec226a442722134539e77c575f6cfaa423f24371bb8d2e94"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:55a23dcd98c858c0db44fc5c04fc7ed81c4b4d33c653a7c45ddaebf6563a2f66"}, + {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:4241204e4b36ab5ae466ecec5c4c16527a054c69f99bba20f6f75232a6a534e2"}, + {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e574de99d735b3fc8364cba9912c2bec2da78775eba95cbb225ef7dda6acea24"}, + {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1302a54f87b5cd8528e4d6d1bf2133b6aa7c6122ff8e9dc5220fbc1e07bffebd"}, + {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8e81e4b55930e5ffab4a68db1af431629cf2e4066dbdbfef65348b8ab804ea8"}, + {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c99462ffc538717b3e60151dfaf91125f637e801f5ab008f81c402f1dff0cd0f"}, + {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e4cf2d5829f6963a5483ec01578ee76d329eb5caf330ecd05b3edd697e7d768a"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:cf10b7d58ae4a1f07fccbf4a0a956d705356fea05fb4c70608bb6fa81d103cda"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:399ac0891c284fa8eb998bcfa323f2234858f5d2efca3950ae58c8f88830f145"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c6a5c79b28003543db3ba67d1df336f253a87d3112dac3a51b94f7d48e4c0e1"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:599c87d79cab2a6a2a9df4aefe0455e61e7d2aeede2f8577c1b7c0aec643ee8e"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e166ad47ba900f2542a80d83f9fc65fe99eb63ceec4debec160ae729824052"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a0b5db001b98e1c649dd55afa928e75aa4087e587b9524a4992316fa23c9fba"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:747265448cb57a9f37572a488a57d873fd96bf51e5bb7edb52cfb37124516da4"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7ebe3416785f65c28f4f9441e916bfc8a54179c8dea73c23023f7086fa601c5d"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:86c963186ca5e50d5c8287b1d1c9d3f8f024cbe343d048c5bd282aec2d8641f2"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e0641b506486f0b4cd1500a2a65740243e8670a2549bb02bc4556a83af84ae03"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71d72ca5eaaa8d38c8df16b7deb1a2da4f650c41b58bb142f3fb75d5ad4a611f"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27e524624eace5c59af499cd97dc18bb201dc6a7a2da24bfc66ef151c69a5f2a"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3dde6cac75e0b0902778978d3b1646ca9f438654395a362cb21d9ad34b24acf"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:00646784f6cd993b1e1c0e7b0fdcbccc375d539db95555477771c27555e3c556"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:23598acb8ccaa3d1d875ef3b35cb6376535095e9405d91a3d57a8c7db5d29341"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7f41533d7e3cf9520065f610b41ac1c76bc2161415955fbcead4981b22c7611e"}, + {file = "pydantic_core-2.14.6.tar.gz", hash = "sha256:1fd0c1d395372843fba13a51c28e3bb9d59bd7aebfeb17358ffaaa1e4dbbe948"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pygments" +version = "2.17.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, + {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, +] + +[package.extras] +plugins = ["importlib-metadata"] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pylint" +version = "3.0.3" +description = "python code static checker" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "pylint-3.0.3-py3-none-any.whl", hash = "sha256:7a1585285aefc5165db81083c3e06363a27448f6b467b3b0f30dbd0ac1f73810"}, + {file = "pylint-3.0.3.tar.gz", hash = "sha256:58c2398b0301e049609a8429789ec6edf3aabe9b6c5fec916acd18639c16de8b"}, +] + +[package.dependencies] +astroid = ">=3.0.1,<=3.1.0-dev0" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +dill = [ + {version = ">=0.2", markers = "python_version < \"3.11\""}, + {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, + {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, +] +isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" +mccabe = ">=0.6,<0.8" +platformdirs = ">=2.2.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +tomlkit = ">=0.10.1" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +spelling = ["pyenchant (>=3.2,<4.0)"] +testutils = ["gitpython (>3)"] + +[[package]] +name = "pylint-plugin-utils" +version = "0.8.2" +description = "Utilities and helpers for writing Pylint plugins" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "pylint_plugin_utils-0.8.2-py3-none-any.whl", hash = "sha256:ae11664737aa2effbf26f973a9e0b6779ab7106ec0adc5fe104b0907ca04e507"}, + {file = "pylint_plugin_utils-0.8.2.tar.gz", hash = "sha256:d3cebf68a38ba3fba23a873809155562571386d4c1b03e5b4c4cc26c3eee93e4"}, +] + +[package.dependencies] +pylint = ">=1.7" + +[[package]] +name = "pylint-pydantic" +version = "0.3.2" +description = "A Pylint plugin to help Pylint understand the Pydantic" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pylint_pydantic-0.3.2-py3-none-any.whl", hash = "sha256:e5cec02370aa68ac8eff138e5d573b0ac049bab864e9a6c3a9057cf043440aa1"}, +] + +[package.dependencies] +pydantic = "<3.0" +pylint = ">2.0,<4.0" +pylint-plugin-utils = "*" + +[[package]] +name = "pymdown-extensions" +version = "10.7" +description = "Extension pack for Python Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pymdown_extensions-10.7-py3-none-any.whl", hash = "sha256:6ca215bc57bc12bf32b414887a68b810637d039124ed9b2e5bd3325cbb2c050c"}, + {file = "pymdown_extensions-10.7.tar.gz", hash = "sha256:c0d64d5cf62566f59e6b2b690a4095c931107c250a8c8e1351c1de5f6b036deb"}, +] + +[package.dependencies] +markdown = ">=3.5" +pyyaml = "*" + +[package.extras] +extra = ["pygments (>=2.12)"] + +[[package]] +name = "pyproject-api" +version = "1.6.1" +description = "API to interact with the python pyproject.toml based projects" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyproject_api-1.6.1-py3-none-any.whl", hash = "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675"}, + {file = "pyproject_api-1.6.1.tar.gz", hash = "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538"}, +] + +[package.dependencies] +packaging = ">=23.1" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2023.8.19)", "sphinx (<7.2)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "setuptools (>=68.1.2)", "wheel (>=0.41.2)"] + +[[package]] +name = "pytest" +version = "7.4.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-lazy-fixtures" +version = "1.0.1" +description = "Allows you to use fixtures in @pytest.mark.parametrize." +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "pytest_lazy_fixtures-1.0.1-py3-none-any.whl", hash = "sha256:b85c30eaec34d1d2c2ad4c3b55a1301b9d89b963725bb50d7931bf38b02b5f8d"}, + {file = "pytest_lazy_fixtures-1.0.1.tar.gz", hash = "sha256:29f11f1ce4e50475b645ea8a2fbcf981508ca911926f73cbb45df17163d48a6a"}, +] + +[package.dependencies] +pytest = ">=7.2.1,<8.0.0" + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytz" +version = "2023.3.post1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, + {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " +optional = false +python-versions = ">=3.6" +files = [ + {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, + {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, +] + +[package.dependencies] +pyyaml = "*" + +[[package]] +name = "referencing" +version = "0.32.1" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "referencing-0.32.1-py3-none-any.whl", hash = "sha256:7e4dc12271d8e15612bfe35792f5ea1c40970dadf8624602e33db2758f7ee554"}, + {file = "referencing-0.32.1.tar.gz", hash = "sha256:3c57da0513e9563eb7e203ebe9bb3a1b509b042016433bd1e45a2853466c3dd3"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" + +[[package]] +name = "regex" +version = "2023.12.25" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.7" +files = [ + {file = "regex-2023.12.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0694219a1d54336fd0445ea382d49d36882415c0134ee1e8332afd1529f0baa5"}, + {file = "regex-2023.12.25-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b014333bd0217ad3d54c143de9d4b9a3ca1c5a29a6d0d554952ea071cff0f1f8"}, + {file = "regex-2023.12.25-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d865984b3f71f6d0af64d0d88f5733521698f6c16f445bb09ce746c92c97c586"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e0eabac536b4cc7f57a5f3d095bfa557860ab912f25965e08fe1545e2ed8b4c"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c25a8ad70e716f96e13a637802813f65d8a6760ef48672aa3502f4c24ea8b400"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9b6d73353f777630626f403b0652055ebfe8ff142a44ec2cf18ae470395766e"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9cc99d6946d750eb75827cb53c4371b8b0fe89c733a94b1573c9dd16ea6c9e4"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88d1f7bef20c721359d8675f7d9f8e414ec5003d8f642fdfd8087777ff7f94b5"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cb3fe77aec8f1995611f966d0c656fdce398317f850d0e6e7aebdfe61f40e1cd"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7aa47c2e9ea33a4a2a05f40fcd3ea36d73853a2aae7b4feab6fc85f8bf2c9704"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:df26481f0c7a3f8739fecb3e81bc9da3fcfae34d6c094563b9d4670b047312e1"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c40281f7d70baf6e0db0c2f7472b31609f5bc2748fe7275ea65a0b4601d9b392"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d94a1db462d5690ebf6ae86d11c5e420042b9898af5dcf278bd97d6bda065423"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ba1b30765a55acf15dce3f364e4928b80858fa8f979ad41f862358939bdd1f2f"}, + {file = "regex-2023.12.25-cp310-cp310-win32.whl", hash = "sha256:150c39f5b964e4d7dba46a7962a088fbc91f06e606f023ce57bb347a3b2d4630"}, + {file = "regex-2023.12.25-cp310-cp310-win_amd64.whl", hash = "sha256:09da66917262d9481c719599116c7dc0c321ffcec4b1f510c4f8a066f8768105"}, + {file = "regex-2023.12.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b9d811f72210fa9306aeb88385b8f8bcef0dfbf3873410413c00aa94c56c2b6"}, + {file = "regex-2023.12.25-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d902a43085a308cef32c0d3aea962524b725403fd9373dea18110904003bac97"}, + {file = "regex-2023.12.25-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d166eafc19f4718df38887b2bbe1467a4f74a9830e8605089ea7a30dd4da8887"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7ad32824b7f02bb3c9f80306d405a1d9b7bb89362d68b3c5a9be53836caebdb"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:636ba0a77de609d6510235b7f0e77ec494d2657108f777e8765efc060094c98c"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fda75704357805eb953a3ee15a2b240694a9a514548cd49b3c5124b4e2ad01b"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f72cbae7f6b01591f90814250e636065850c5926751af02bb48da94dfced7baa"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2a0b1857f18b11e3b0e54ddfefc96af46b0896fb678c85f63fb8c37518b3e7"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7502534e55c7c36c0978c91ba6f61703faf7ce733715ca48f499d3dbbd7657e0"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e8c7e08bb566de4faaf11984af13f6bcf6a08f327b13631d41d62592681d24fe"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:283fc8eed679758de38fe493b7d7d84a198b558942b03f017b1f94dda8efae80"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f44dd4d68697559d007462b0a3a1d9acd61d97072b71f6d1968daef26bc744bd"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:67d3ccfc590e5e7197750fcb3a2915b416a53e2de847a728cfa60141054123d4"}, + {file = "regex-2023.12.25-cp311-cp311-win32.whl", hash = "sha256:68191f80a9bad283432385961d9efe09d783bcd36ed35a60fb1ff3f1ec2efe87"}, + {file = "regex-2023.12.25-cp311-cp311-win_amd64.whl", hash = "sha256:7d2af3f6b8419661a0c421584cfe8aaec1c0e435ce7e47ee2a97e344b98f794f"}, + {file = "regex-2023.12.25-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8a0ccf52bb37d1a700375a6b395bff5dd15c50acb745f7db30415bae3c2b0715"}, + {file = "regex-2023.12.25-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c3c4a78615b7762740531c27cf46e2f388d8d727d0c0c739e72048beb26c8a9d"}, + {file = "regex-2023.12.25-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ad83e7545b4ab69216cef4cc47e344d19622e28aabec61574b20257c65466d6a"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7a635871143661feccce3979e1727c4e094f2bdfd3ec4b90dfd4f16f571a87a"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d498eea3f581fbe1b34b59c697512a8baef88212f92e4c7830fcc1499f5b45a5"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f7cd5754d02a56ae4ebb91b33461dc67be8e3e0153f593c509e21d219c5060"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51f4b32f793812714fd5307222a7f77e739b9bc566dc94a18126aba3b92b98a3"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba99d8077424501b9616b43a2d208095746fb1284fc5ba490139651f971d39d9"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4bfc2b16e3ba8850e0e262467275dd4d62f0d045e0e9eda2bc65078c0110a11f"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8c2c19dae8a3eb0ea45a8448356ed561be843b13cbc34b840922ddf565498c1c"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:60080bb3d8617d96f0fb7e19796384cc2467447ef1c491694850ebd3670bc457"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b77e27b79448e34c2c51c09836033056a0547aa360c45eeeb67803da7b0eedaf"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:518440c991f514331f4850a63560321f833979d145d7d81186dbe2f19e27ae3d"}, + {file = "regex-2023.12.25-cp312-cp312-win32.whl", hash = "sha256:e2610e9406d3b0073636a3a2e80db05a02f0c3169b5632022b4e81c0364bcda5"}, + {file = "regex-2023.12.25-cp312-cp312-win_amd64.whl", hash = "sha256:cc37b9aeebab425f11f27e5e9e6cf580be7206c6582a64467a14dda211abc232"}, + {file = "regex-2023.12.25-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:da695d75ac97cb1cd725adac136d25ca687da4536154cdc2815f576e4da11c69"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d126361607b33c4eb7b36debc173bf25d7805847346dd4d99b5499e1fef52bc7"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4719bb05094d7d8563a450cf8738d2e1061420f79cfcc1fa7f0a44744c4d8f73"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5dd58946bce44b53b06d94aa95560d0b243eb2fe64227cba50017a8d8b3cd3e2"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22a86d9fff2009302c440b9d799ef2fe322416d2d58fc124b926aa89365ec482"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aae8101919e8aa05ecfe6322b278f41ce2994c4a430303c4cd163fef746e04f"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e692296c4cc2873967771345a876bcfc1c547e8dd695c6b89342488b0ea55cd8"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:263ef5cc10979837f243950637fffb06e8daed7f1ac1e39d5910fd29929e489a"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d6f7e255e5fa94642a0724e35406e6cb7001c09d476ab5fce002f652b36d0c39"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:88ad44e220e22b63b0f8f81f007e8abbb92874d8ced66f32571ef8beb0643b2b"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:3a17d3ede18f9cedcbe23d2daa8a2cd6f59fe2bf082c567e43083bba3fb00347"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d15b274f9e15b1a0b7a45d2ac86d1f634d983ca40d6b886721626c47a400bf39"}, + {file = "regex-2023.12.25-cp37-cp37m-win32.whl", hash = "sha256:ed19b3a05ae0c97dd8f75a5d8f21f7723a8c33bbc555da6bbe1f96c470139d3c"}, + {file = "regex-2023.12.25-cp37-cp37m-win_amd64.whl", hash = "sha256:a6d1047952c0b8104a1d371f88f4ab62e6275567d4458c1e26e9627ad489b445"}, + {file = "regex-2023.12.25-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b43523d7bc2abd757119dbfb38af91b5735eea45537ec6ec3a5ec3f9562a1c53"}, + {file = "regex-2023.12.25-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:efb2d82f33b2212898f1659fb1c2e9ac30493ac41e4d53123da374c3b5541e64"}, + {file = "regex-2023.12.25-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b7fca9205b59c1a3d5031f7e64ed627a1074730a51c2a80e97653e3e9fa0d415"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086dd15e9435b393ae06f96ab69ab2d333f5d65cbe65ca5a3ef0ec9564dfe770"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e81469f7d01efed9b53740aedd26085f20d49da65f9c1f41e822a33992cb1590"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34e4af5b27232f68042aa40a91c3b9bb4da0eeb31b7632e0091afc4310afe6cb"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9852b76ab558e45b20bf1893b59af64a28bd3820b0c2efc80e0a70a4a3ea51c1"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff100b203092af77d1a5a7abe085b3506b7eaaf9abf65b73b7d6905b6cb76988"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cc038b2d8b1470364b1888a98fd22d616fba2b6309c5b5f181ad4483e0017861"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:094ba386bb5c01e54e14434d4caabf6583334090865b23ef58e0424a6286d3dc"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5cd05d0f57846d8ba4b71d9c00f6f37d6b97d5e5ef8b3c3840426a475c8f70f4"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:9aa1a67bbf0f957bbe096375887b2505f5d8ae16bf04488e8b0f334c36e31360"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:98a2636994f943b871786c9e82bfe7883ecdaba2ef5df54e1450fa9869d1f756"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:37f8e93a81fc5e5bd8db7e10e62dc64261bcd88f8d7e6640aaebe9bc180d9ce2"}, + {file = "regex-2023.12.25-cp38-cp38-win32.whl", hash = "sha256:d78bd484930c1da2b9679290a41cdb25cc127d783768a0369d6b449e72f88beb"}, + {file = "regex-2023.12.25-cp38-cp38-win_amd64.whl", hash = "sha256:b521dcecebc5b978b447f0f69b5b7f3840eac454862270406a39837ffae4e697"}, + {file = "regex-2023.12.25-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f7bc09bc9c29ebead055bcba136a67378f03d66bf359e87d0f7c759d6d4ffa31"}, + {file = "regex-2023.12.25-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e14b73607d6231f3cc4622809c196b540a6a44e903bcfad940779c80dffa7be7"}, + {file = "regex-2023.12.25-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9eda5f7a50141291beda3edd00abc2d4a5b16c29c92daf8d5bd76934150f3edc"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc6bb9aa69aacf0f6032c307da718f61a40cf970849e471254e0e91c56ffca95"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:298dc6354d414bc921581be85695d18912bea163a8b23cac9a2562bbcd5088b1"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f4e475a80ecbd15896a976aa0b386c5525d0ed34d5c600b6d3ebac0a67c7ddf"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:531ac6cf22b53e0696f8e1d56ce2396311254eb806111ddd3922c9d937151dae"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22f3470f7524b6da61e2020672df2f3063676aff444db1daa283c2ea4ed259d6"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:89723d2112697feaa320c9d351e5f5e7b841e83f8b143dba8e2d2b5f04e10923"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0ecf44ddf9171cd7566ef1768047f6e66975788258b1c6c6ca78098b95cf9a3d"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:905466ad1702ed4acfd67a902af50b8db1feeb9781436372261808df7a2a7bca"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:4558410b7a5607a645e9804a3e9dd509af12fb72b9825b13791a37cd417d73a5"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:7e316026cc1095f2a3e8cc012822c99f413b702eaa2ca5408a513609488cb62f"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3b1de218d5375cd6ac4b5493e0b9f3df2be331e86520f23382f216c137913d20"}, + {file = "regex-2023.12.25-cp39-cp39-win32.whl", hash = "sha256:11a963f8e25ab5c61348d090bf1b07f1953929c13bd2309a0662e9ff680763c9"}, + {file = "regex-2023.12.25-cp39-cp39-win_amd64.whl", hash = "sha256:e693e233ac92ba83a87024e1d32b5f9ab15ca55ddd916d878146f4e3406b5c91"}, + {file = "regex-2023.12.25.tar.gz", hash = "sha256:29171aa128da69afdf4bde412d5bedc335f2ca8fcfe4489038577d05f16181e5"}, +] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rich" +version = "13.7.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.7.0-py3-none-any.whl", hash = "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235"}, + {file = "rich-13.7.0.tar.gz", hash = "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "rpds-py" +version = "0.17.1" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "rpds_py-0.17.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4128980a14ed805e1b91a7ed551250282a8ddf8201a4e9f8f5b7e6225f54170d"}, + {file = "rpds_py-0.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ff1dcb8e8bc2261a088821b2595ef031c91d499a0c1b031c152d43fe0a6ecec8"}, + {file = "rpds_py-0.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d65e6b4f1443048eb7e833c2accb4fa7ee67cc7d54f31b4f0555b474758bee55"}, + {file = "rpds_py-0.17.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a71169d505af63bb4d20d23a8fbd4c6ce272e7bce6cc31f617152aa784436f29"}, + {file = "rpds_py-0.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:436474f17733c7dca0fbf096d36ae65277e8645039df12a0fa52445ca494729d"}, + {file = "rpds_py-0.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10162fe3f5f47c37ebf6d8ff5a2368508fe22007e3077bf25b9c7d803454d921"}, + {file = "rpds_py-0.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:720215373a280f78a1814becb1312d4e4d1077b1202a56d2b0815e95ccb99ce9"}, + {file = "rpds_py-0.17.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:70fcc6c2906cfa5c6a552ba7ae2ce64b6c32f437d8f3f8eea49925b278a61453"}, + {file = "rpds_py-0.17.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91e5a8200e65aaac342a791272c564dffcf1281abd635d304d6c4e6b495f29dc"}, + {file = "rpds_py-0.17.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:99f567dae93e10be2daaa896e07513dd4bf9c2ecf0576e0533ac36ba3b1d5394"}, + {file = "rpds_py-0.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24e4900a6643f87058a27320f81336d527ccfe503984528edde4bb660c8c8d59"}, + {file = "rpds_py-0.17.1-cp310-none-win32.whl", hash = "sha256:0bfb09bf41fe7c51413f563373e5f537eaa653d7adc4830399d4e9bdc199959d"}, + {file = "rpds_py-0.17.1-cp310-none-win_amd64.whl", hash = "sha256:20de7b7179e2031a04042e85dc463a93a82bc177eeba5ddd13ff746325558aa6"}, + {file = "rpds_py-0.17.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:65dcf105c1943cba45d19207ef51b8bc46d232a381e94dd38719d52d3980015b"}, + {file = "rpds_py-0.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:01f58a7306b64e0a4fe042047dd2b7d411ee82e54240284bab63e325762c1147"}, + {file = "rpds_py-0.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:071bc28c589b86bc6351a339114fb7a029f5cddbaca34103aa573eba7b482382"}, + {file = "rpds_py-0.17.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ae35e8e6801c5ab071b992cb2da958eee76340e6926ec693b5ff7d6381441745"}, + {file = "rpds_py-0.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149c5cd24f729e3567b56e1795f74577aa3126c14c11e457bec1b1c90d212e38"}, + {file = "rpds_py-0.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e796051f2070f47230c745d0a77a91088fbee2cc0502e9b796b9c6471983718c"}, + {file = "rpds_py-0.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e820ee1004327609b28db8307acc27f5f2e9a0b185b2064c5f23e815f248f8"}, + {file = "rpds_py-0.17.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1957a2ab607f9added64478a6982742eb29f109d89d065fa44e01691a20fc20a"}, + {file = "rpds_py-0.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8587fd64c2a91c33cdc39d0cebdaf30e79491cc029a37fcd458ba863f8815383"}, + {file = "rpds_py-0.17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4dc889a9d8a34758d0fcc9ac86adb97bab3fb7f0c4d29794357eb147536483fd"}, + {file = "rpds_py-0.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2953937f83820376b5979318840f3ee47477d94c17b940fe31d9458d79ae7eea"}, + {file = "rpds_py-0.17.1-cp311-none-win32.whl", hash = "sha256:1bfcad3109c1e5ba3cbe2f421614e70439f72897515a96c462ea657261b96518"}, + {file = "rpds_py-0.17.1-cp311-none-win_amd64.whl", hash = "sha256:99da0a4686ada4ed0f778120a0ea8d066de1a0a92ab0d13ae68492a437db78bf"}, + {file = "rpds_py-0.17.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1dc29db3900cb1bb40353772417800f29c3d078dbc8024fd64655a04ee3c4bdf"}, + {file = "rpds_py-0.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82ada4a8ed9e82e443fcef87e22a3eed3654dd3adf6e3b3a0deb70f03e86142a"}, + {file = "rpds_py-0.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d36b2b59e8cc6e576f8f7b671e32f2ff43153f0ad6d0201250a7c07f25d570e"}, + {file = "rpds_py-0.17.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3677fcca7fb728c86a78660c7fb1b07b69b281964673f486ae72860e13f512ad"}, + {file = "rpds_py-0.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:516fb8c77805159e97a689e2f1c80655c7658f5af601c34ffdb916605598cda2"}, + {file = "rpds_py-0.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df3b6f45ba4515632c5064e35ca7f31d51d13d1479673185ba8f9fefbbed58b9"}, + {file = "rpds_py-0.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a967dd6afda7715d911c25a6ba1517975acd8d1092b2f326718725461a3d33f9"}, + {file = "rpds_py-0.17.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dbbb95e6fc91ea3102505d111b327004d1c4ce98d56a4a02e82cd451f9f57140"}, + {file = "rpds_py-0.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02866e060219514940342a1f84303a1ef7a1dad0ac311792fbbe19b521b489d2"}, + {file = "rpds_py-0.17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2528ff96d09f12e638695f3a2e0c609c7b84c6df7c5ae9bfeb9252b6fa686253"}, + {file = "rpds_py-0.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd345a13ce06e94c753dab52f8e71e5252aec1e4f8022d24d56decd31e1b9b23"}, + {file = "rpds_py-0.17.1-cp312-none-win32.whl", hash = "sha256:2a792b2e1d3038daa83fa474d559acfd6dc1e3650ee93b2662ddc17dbff20ad1"}, + {file = "rpds_py-0.17.1-cp312-none-win_amd64.whl", hash = "sha256:292f7344a3301802e7c25c53792fae7d1593cb0e50964e7bcdcc5cf533d634e3"}, + {file = "rpds_py-0.17.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:8ffe53e1d8ef2520ebcf0c9fec15bb721da59e8ef283b6ff3079613b1e30513d"}, + {file = "rpds_py-0.17.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4341bd7579611cf50e7b20bb8c2e23512a3dc79de987a1f411cb458ab670eb90"}, + {file = "rpds_py-0.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f4eb548daf4836e3b2c662033bfbfc551db58d30fd8fe660314f86bf8510b93"}, + {file = "rpds_py-0.17.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b686f25377f9c006acbac63f61614416a6317133ab7fafe5de5f7dc8a06d42eb"}, + {file = "rpds_py-0.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e21b76075c01d65d0f0f34302b5a7457d95721d5e0667aea65e5bb3ab415c25"}, + {file = "rpds_py-0.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b86b21b348f7e5485fae740d845c65a880f5d1eda1e063bc59bef92d1f7d0c55"}, + {file = "rpds_py-0.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f175e95a197f6a4059b50757a3dca33b32b61691bdbd22c29e8a8d21d3914cae"}, + {file = "rpds_py-0.17.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1701fc54460ae2e5efc1dd6350eafd7a760f516df8dbe51d4a1c79d69472fbd4"}, + {file = "rpds_py-0.17.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9051e3d2af8f55b42061603e29e744724cb5f65b128a491446cc029b3e2ea896"}, + {file = "rpds_py-0.17.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:7450dbd659fed6dd41d1a7d47ed767e893ba402af8ae664c157c255ec6067fde"}, + {file = "rpds_py-0.17.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5a024fa96d541fd7edaa0e9d904601c6445e95a729a2900c5aec6555fe921ed6"}, + {file = "rpds_py-0.17.1-cp38-none-win32.whl", hash = "sha256:da1ead63368c04a9bded7904757dfcae01eba0e0f9bc41d3d7f57ebf1c04015a"}, + {file = "rpds_py-0.17.1-cp38-none-win_amd64.whl", hash = "sha256:841320e1841bb53fada91c9725e766bb25009cfd4144e92298db296fb6c894fb"}, + {file = "rpds_py-0.17.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:f6c43b6f97209e370124baf2bf40bb1e8edc25311a158867eb1c3a5d449ebc7a"}, + {file = "rpds_py-0.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7d63ec01fe7c76c2dbb7e972fece45acbb8836e72682bde138e7e039906e2c"}, + {file = "rpds_py-0.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81038ff87a4e04c22e1d81f947c6ac46f122e0c80460b9006e6517c4d842a6ec"}, + {file = "rpds_py-0.17.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:810685321f4a304b2b55577c915bece4c4a06dfe38f6e62d9cc1d6ca8ee86b99"}, + {file = "rpds_py-0.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25f071737dae674ca8937a73d0f43f5a52e92c2d178330b4c0bb6ab05586ffa6"}, + {file = "rpds_py-0.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa5bfb13f1e89151ade0eb812f7b0d7a4d643406caaad65ce1cbabe0a66d695f"}, + {file = "rpds_py-0.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfe07308b311a8293a0d5ef4e61411c5c20f682db6b5e73de6c7c8824272c256"}, + {file = "rpds_py-0.17.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a000133a90eea274a6f28adc3084643263b1e7c1a5a66eb0a0a7a36aa757ed74"}, + {file = "rpds_py-0.17.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d0e8a6434a3fbf77d11448c9c25b2f25244226cfbec1a5159947cac5b8c5fa4"}, + {file = "rpds_py-0.17.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efa767c220d94aa4ac3a6dd3aeb986e9f229eaf5bce92d8b1b3018d06bed3772"}, + {file = "rpds_py-0.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:dbc56680ecf585a384fbd93cd42bc82668b77cb525343170a2d86dafaed2a84b"}, + {file = "rpds_py-0.17.1-cp39-none-win32.whl", hash = "sha256:270987bc22e7e5a962b1094953ae901395e8c1e1e83ad016c5cfcfff75a15a3f"}, + {file = "rpds_py-0.17.1-cp39-none-win_amd64.whl", hash = "sha256:2a7b2f2f56a16a6d62e55354dd329d929560442bd92e87397b7a9586a32e3e76"}, + {file = "rpds_py-0.17.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a3264e3e858de4fc601741498215835ff324ff2482fd4e4af61b46512dd7fc83"}, + {file = "rpds_py-0.17.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f2f3b28b40fddcb6c1f1f6c88c6f3769cd933fa493ceb79da45968a21dccc920"}, + {file = "rpds_py-0.17.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9584f8f52010295a4a417221861df9bea4c72d9632562b6e59b3c7b87a1522b7"}, + {file = "rpds_py-0.17.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c64602e8be701c6cfe42064b71c84ce62ce66ddc6422c15463fd8127db3d8066"}, + {file = "rpds_py-0.17.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:060f412230d5f19fc8c8b75f315931b408d8ebf56aec33ef4168d1b9e54200b1"}, + {file = "rpds_py-0.17.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9412abdf0ba70faa6e2ee6c0cc62a8defb772e78860cef419865917d86c7342"}, + {file = "rpds_py-0.17.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9737bdaa0ad33d34c0efc718741abaafce62fadae72c8b251df9b0c823c63b22"}, + {file = "rpds_py-0.17.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9f0e4dc0f17dcea4ab9d13ac5c666b6b5337042b4d8f27e01b70fae41dd65c57"}, + {file = "rpds_py-0.17.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:1db228102ab9d1ff4c64148c96320d0be7044fa28bd865a9ce628ce98da5973d"}, + {file = "rpds_py-0.17.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:d8bbd8e56f3ba25a7d0cf980fc42b34028848a53a0e36c9918550e0280b9d0b6"}, + {file = "rpds_py-0.17.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:be22ae34d68544df293152b7e50895ba70d2a833ad9566932d750d3625918b82"}, + {file = "rpds_py-0.17.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:bf046179d011e6114daf12a534d874958b039342b347348a78b7cdf0dd9d6041"}, + {file = "rpds_py-0.17.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:1a746a6d49665058a5896000e8d9d2f1a6acba8a03b389c1e4c06e11e0b7f40d"}, + {file = "rpds_py-0.17.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0b8bf5b8db49d8fd40f54772a1dcf262e8be0ad2ab0206b5a2ec109c176c0a4"}, + {file = "rpds_py-0.17.1-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f7f4cb1f173385e8a39c29510dd11a78bf44e360fb75610594973f5ea141028b"}, + {file = "rpds_py-0.17.1-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7fbd70cb8b54fe745301921b0816c08b6d917593429dfc437fd024b5ba713c58"}, + {file = "rpds_py-0.17.1-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bdf1303df671179eaf2cb41e8515a07fc78d9d00f111eadbe3e14262f59c3d0"}, + {file = "rpds_py-0.17.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad059a4bd14c45776600d223ec194e77db6c20255578bb5bcdd7c18fd169361"}, + {file = "rpds_py-0.17.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3664d126d3388a887db44c2e293f87d500c4184ec43d5d14d2d2babdb4c64cad"}, + {file = "rpds_py-0.17.1-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:698ea95a60c8b16b58be9d854c9f993c639f5c214cf9ba782eca53a8789d6b19"}, + {file = "rpds_py-0.17.1-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:c3d2010656999b63e628a3c694f23020322b4178c450dc478558a2b6ef3cb9bb"}, + {file = "rpds_py-0.17.1-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:938eab7323a736533f015e6069a7d53ef2dcc841e4e533b782c2bfb9fb12d84b"}, + {file = "rpds_py-0.17.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e626b365293a2142a62b9a614e1f8e331b28f3ca57b9f05ebbf4cf2a0f0bdc5"}, + {file = "rpds_py-0.17.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:380e0df2e9d5d5d339803cfc6d183a5442ad7ab3c63c2a0982e8c824566c5ccc"}, + {file = "rpds_py-0.17.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b760a56e080a826c2e5af09002c1a037382ed21d03134eb6294812dda268c811"}, + {file = "rpds_py-0.17.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5576ee2f3a309d2bb403ec292d5958ce03953b0e57a11d224c1f134feaf8c40f"}, + {file = "rpds_py-0.17.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3c3461ebb4c4f1bbc70b15d20b565759f97a5aaf13af811fcefc892e9197ba"}, + {file = "rpds_py-0.17.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:637b802f3f069a64436d432117a7e58fab414b4e27a7e81049817ae94de45d8d"}, + {file = "rpds_py-0.17.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffee088ea9b593cc6160518ba9bd319b5475e5f3e578e4552d63818773c6f56a"}, + {file = "rpds_py-0.17.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3ac732390d529d8469b831949c78085b034bff67f584559340008d0f6041a049"}, + {file = "rpds_py-0.17.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:93432e747fb07fa567ad9cc7aaadd6e29710e515aabf939dfbed8046041346c6"}, + {file = "rpds_py-0.17.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:7b7d9ca34542099b4e185b3c2a2b2eda2e318a7dbde0b0d83357a6d4421b5296"}, + {file = "rpds_py-0.17.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:0387ce69ba06e43df54e43968090f3626e231e4bc9150e4c3246947567695f68"}, + {file = "rpds_py-0.17.1.tar.gz", hash = "sha256:0210b2668f24c078307260bf88bdac9d6f1093635df5123789bfee4d8d7fc8e7"}, +] + +[[package]] +name = "ruff" +version = "0.1.14" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.1.14-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:96f76536df9b26622755c12ed8680f159817be2f725c17ed9305b472a757cdbb"}, + {file = "ruff-0.1.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab3f71f64498c7241123bb5a768544cf42821d2a537f894b22457a543d3ca7a9"}, + {file = "ruff-0.1.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7060156ecc572b8f984fd20fd8b0fcb692dd5d837b7606e968334ab7ff0090ab"}, + {file = "ruff-0.1.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a53d8e35313d7b67eb3db15a66c08434809107659226a90dcd7acb2afa55faea"}, + {file = "ruff-0.1.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bea9be712b8f5b4ebed40e1949379cfb2a7d907f42921cf9ab3aae07e6fba9eb"}, + {file = "ruff-0.1.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2270504d629a0b064247983cbc495bed277f372fb9eaba41e5cf51f7ba705a6a"}, + {file = "ruff-0.1.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80258bb3b8909b1700610dfabef7876423eed1bc930fe177c71c414921898efa"}, + {file = "ruff-0.1.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:653230dd00aaf449eb5ff25d10a6e03bc3006813e2cb99799e568f55482e5cae"}, + {file = "ruff-0.1.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b3acc6c4e6928459ba9eb7459dd4f0c4bf266a053c863d72a44c33246bfdbf"}, + {file = "ruff-0.1.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b3dadc9522d0eccc060699a9816e8127b27addbb4697fc0c08611e4e6aeb8b5"}, + {file = "ruff-0.1.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1c8eca1a47b4150dc0fbec7fe68fc91c695aed798532a18dbb1424e61e9b721f"}, + {file = "ruff-0.1.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:62ce2ae46303ee896fc6811f63d6dabf8d9c389da0f3e3f2bce8bc7f15ef5488"}, + {file = "ruff-0.1.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b2027dde79d217b211d725fc833e8965dc90a16d0d3213f1298f97465956661b"}, + {file = "ruff-0.1.14-py3-none-win32.whl", hash = "sha256:722bafc299145575a63bbd6b5069cb643eaa62546a5b6398f82b3e4403329cab"}, + {file = "ruff-0.1.14-py3-none-win_amd64.whl", hash = "sha256:e3d241aa61f92b0805a7082bd89a9990826448e4d0398f0e2bc8f05c75c63d99"}, + {file = "ruff-0.1.14-py3-none-win_arm64.whl", hash = "sha256:269302b31ade4cde6cf6f9dd58ea593773a37ed3f7b97e793c8594b262466b67"}, + {file = "ruff-0.1.14.tar.gz", hash = "sha256:ad3f8088b2dfd884820289a06ab718cde7d38b94972212cc4ba90d5fbc9955f3"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] + +[[package]] +name = "starlette" +version = "0.35.1" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette-0.35.1-py3-none-any.whl", hash = "sha256:50bbbda9baa098e361f398fda0928062abbaf1f54f4fadcbe17c092a01eb9a25"}, + {file = "starlette-0.35.1.tar.gz", hash = "sha256:3e2639dac3520e4f58734ed22553f950d3f3cb1001cd2eaac4d57e8cdc5f66bc"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] + +[[package]] +name = "stevedore" +version = "5.1.0" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.8" +files = [ + {file = "stevedore-5.1.0-py3-none-any.whl", hash = "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d"}, + {file = "stevedore-5.1.0.tar.gz", hash = "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c"}, +] + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "tomlkit" +version = "0.12.3" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomlkit-0.12.3-py3-none-any.whl", hash = "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba"}, + {file = "tomlkit-0.12.3.tar.gz", hash = "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4"}, +] + +[[package]] +name = "tox" +version = "4.12.1" +description = "tox is a generic virtualenv management and test command line tool" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tox-4.12.1-py3-none-any.whl", hash = "sha256:c07ea797880a44f3c4f200ad88ad92b446b83079d4ccef89585df64cc574375c"}, + {file = "tox-4.12.1.tar.gz", hash = "sha256:61aafbeff1bd8a5af84e54ef6e8402f53c6a6066d0782336171ddfbf5362122e"}, +] + +[package.dependencies] +cachetools = ">=5.3.2" +chardet = ">=5.2" +colorama = ">=0.4.6" +filelock = ">=3.13.1" +packaging = ">=23.2" +platformdirs = ">=4.1" +pluggy = ">=1.3" +pyproject-api = ">=1.6.1" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} +virtualenv = ">=20.25" + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.25.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] +testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=8.0.2)", "distlib (>=0.3.8)", "flaky (>=3.7)", "hatch-vcs (>=0.4)", "hatchling (>=1.21)", "psutil (>=5.9.7)", "pytest (>=7.4.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-xdist (>=3.5)", "re-assert (>=1.1)", "time-machine (>=2.13)", "wheel (>=0.42)"] + +[[package]] +name = "typing-extensions" +version = "4.9.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, + {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, +] + +[[package]] +name = "urllib3" +version = "2.1.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, + {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "uvicorn" +version = "0.25.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.25.0-py3-none-any.whl", hash = "sha256:ce107f5d9bd02b4636001a77a4e74aab5e1e2b146868ebbad565237145af444c"}, + {file = "uvicorn-0.25.0.tar.gz", hash = "sha256:6dddbad1d7ee0f5140aba5ec138ddc9612c5109399903828b4874c9937f009c2"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "virtualenv" +version = "20.25.0" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.25.0-py3-none-any.whl", hash = "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3"}, + {file = "virtualenv-20.25.0.tar.gz", hash = "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[[package]] +name = "watchdog" +version = "3.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.7" +files = [ + {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, + {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, + {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, + {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"}, + {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"}, + {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"}, + {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"}, + {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"}, + {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"}, + {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"}, + {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"}, + {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"}, + {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"}, + {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"}, + {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"}, + {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"}, + {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"}, + {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"}, + {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"}, + {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, + {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "zipp" +version = "3.17.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, + {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.8,<4.0" +content-hash = "725092e0e0479e8d2a545aa3af450280f294317f4c92a5b5dfe4a8f0d0893f4f" From c0619a247ef5288da69a6f9f79c8270d7d1c82a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 27 Jan 2024 22:20:27 -0300 Subject: [PATCH 35/66] Remove HAL's method and description --- fastapi_hypermodel/hal.py | 6 ------ tests/test_hal.py | 10 ---------- 2 files changed, 16 deletions(-) diff --git a/fastapi_hypermodel/hal.py b/fastapi_hypermodel/hal.py index 79c1015..d847d83 100644 --- a/fastapi_hypermodel/hal.py +++ b/fastapi_hypermodel/hal.py @@ -34,8 +34,6 @@ class HALForType(BaseModel): hreflang: Optional[str] = None profile: Optional[str] = None deprecation: Optional[str] = None - method: Optional[str] = None - description: Optional[str] = None def __bool__(self: Self) -> bool: return bool(self.href) @@ -45,7 +43,6 @@ class HALFor(HALForType, AbstractHyperField[HALForType]): # pylint: disable=too-many-instance-attributes _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() - _description: Optional[str] = PrivateAttr() _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() _templated: Optional[bool] = PrivateAttr() # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal @@ -104,14 +101,11 @@ def __call__( return HALForType() route = get_route_from_app(app, self._endpoint) - method = next(iter(route.methods), "GET") if route.methods else "GET" uri_path = self._get_uri_path(app, values, route) return HALForType( href=uri_path, - method=method, - description=self._description, templated=self._templated, title=self._title, name=self._name, diff --git a/tests/test_hal.py b/tests/test_hal.py index 01ebb76..59c6a0e 100644 --- a/tests/test_hal.py +++ b/tests/test_hal.py @@ -140,16 +140,6 @@ def hal_for_properties() -> Any: "default": None, "title": "Deprecation", }, - "method": { - "anyOf": [{"type": "string"}, {"type": "null"}], - "default": None, - "title": "Method", - }, - "description": { - "anyOf": [{"type": "string"}, {"type": "null"}], - "default": None, - "title": "Description", - }, } From e4468e7f0fe1754a927762606ecb5862855ad774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 00:08:46 -0300 Subject: [PATCH 36/66] Divide project into individual components --- fastapi_hypermodel/__init__.py | 23 +- fastapi_hypermodel/base/__init__.py | 22 + fastapi_hypermodel/{ => base}/hypermodel.py | 21 +- fastapi_hypermodel/{ => base}/url_type.py | 0 fastapi_hypermodel/{ => base}/utils.py | 0 fastapi_hypermodel/hal.py | 312 ----------- fastapi_hypermodel/hal/__init__.py | 10 + fastapi_hypermodel/hal/hal_hypermodel.py | 191 +++++++ fastapi_hypermodel/hal/hal_response.py | 162 ++++++ fastapi_hypermodel/siren.py | 500 ------------------ fastapi_hypermodel/siren/__init__.py | 33 ++ fastapi_hypermodel/siren/siren_action.py | 171 ++++++ fastapi_hypermodel/siren/siren_base.py | 24 + fastapi_hypermodel/siren/siren_field.py | 43 ++ fastapi_hypermodel/siren/siren_hypermodel.py | 189 +++++++ fastapi_hypermodel/siren/siren_link.py | 113 ++++ fastapi_hypermodel/siren/siren_response.py | 40 ++ .../{ => siren}/siren_schema.py | 0 fastapi_hypermodel/url_for/__init__.py | 3 + fastapi_hypermodel/{ => url_for}/url_for.py | 8 +- 20 files changed, 1036 insertions(+), 829 deletions(-) create mode 100644 fastapi_hypermodel/base/__init__.py rename fastapi_hypermodel/{ => base}/hypermodel.py (82%) rename fastapi_hypermodel/{ => base}/url_type.py (100%) rename fastapi_hypermodel/{ => base}/utils.py (100%) delete mode 100644 fastapi_hypermodel/hal.py create mode 100644 fastapi_hypermodel/hal/__init__.py create mode 100644 fastapi_hypermodel/hal/hal_hypermodel.py create mode 100644 fastapi_hypermodel/hal/hal_response.py delete mode 100644 fastapi_hypermodel/siren.py create mode 100644 fastapi_hypermodel/siren/__init__.py create mode 100644 fastapi_hypermodel/siren/siren_action.py create mode 100644 fastapi_hypermodel/siren/siren_base.py create mode 100644 fastapi_hypermodel/siren/siren_field.py create mode 100644 fastapi_hypermodel/siren/siren_hypermodel.py create mode 100644 fastapi_hypermodel/siren/siren_link.py create mode 100644 fastapi_hypermodel/siren/siren_response.py rename fastapi_hypermodel/{ => siren}/siren_schema.py (100%) create mode 100644 fastapi_hypermodel/url_for/__init__.py rename fastapi_hypermodel/{ => url_for}/url_for.py (93%) diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index 8cdc3d4..97ff735 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -1,10 +1,16 @@ -from .hal import HALFor, HALForType, HalHyperModel, HALResponse -from .hypermodel import ( +from .base import ( + URL_TYPE_SCHEMA, AbstractHyperField, HasName, HyperModel, + InvalidAttribute, + UrlType, + extract_value_by_name, + get_hal_link_href, + get_route_from_app, + resolve_param_values, ) -from .linkset import LinkSet, LinkSetType +from .hal import HALFor, HALForType, HalHyperModel, HALLinkType, HALResponse from .siren import ( SirenActionFor, SirenActionType, @@ -18,27 +24,18 @@ get_siren_link, ) from .url_for import UrlFor -from .url_type import URL_TYPE_SCHEMA, UrlType -from .utils import ( - InvalidAttribute, - extract_value_by_name, - get_hal_link_href, - get_route_from_app, - resolve_param_values, -) __all__ = [ "URL_TYPE_SCHEMA", "AbstractHyperField", "HALFor", "HALForType", + "HALLinkType", "HALResponse", "HalHyperModel", "HasName", "HyperModel", "InvalidAttribute", - "LinkSet", - "LinkSetType", "SirenActionFor", "SirenActionType", "SirenEmbeddedType", diff --git a/fastapi_hypermodel/base/__init__.py b/fastapi_hypermodel/base/__init__.py new file mode 100644 index 0000000..0ff8f8e --- /dev/null +++ b/fastapi_hypermodel/base/__init__.py @@ -0,0 +1,22 @@ +from .hypermodel import AbstractHyperField, HasName, HyperModel +from .url_type import URL_TYPE_SCHEMA, UrlType +from .utils import ( + InvalidAttribute, + extract_value_by_name, + get_hal_link_href, + get_route_from_app, + resolve_param_values, +) + +__all__ = [ + "URL_TYPE_SCHEMA", + "AbstractHyperField", + "HasName", + "HyperModel", + "InvalidAttribute", + "UrlType", + "extract_value_by_name", + "get_hal_link_href", + "get_route_from_app", + "resolve_param_values", +] diff --git a/fastapi_hypermodel/hypermodel.py b/fastapi_hypermodel/base/hypermodel.py similarity index 82% rename from fastapi_hypermodel/hypermodel.py rename to fastapi_hypermodel/base/hypermodel.py index ae04c28..9312877 100644 --- a/fastapi_hypermodel/hypermodel.py +++ b/fastapi_hypermodel/base/hypermodel.py @@ -3,6 +3,7 @@ from string import Formatter from typing import ( Any, + Callable, ClassVar, Dict, Generic, @@ -10,6 +11,7 @@ Mapping, Optional, Protocol, + Sequence, Type, TypeVar, cast, @@ -25,7 +27,7 @@ from starlette.applications import Starlette from typing_extensions import Self -from fastapi_hypermodel.utils import extract_value_by_name +from fastapi_hypermodel.base.utils import extract_value_by_name @runtime_checkable @@ -70,6 +72,9 @@ def __call__( raise NotImplementedError +T2 = TypeVar("T2", bound=Callable[..., Any]) + + class HyperModel(BaseModel): _app: ClassVar[Optional[Starlette]] = None @@ -119,3 +124,17 @@ def _parse_uri(values: Any, uri_template: str) -> str: def parse_uri(self: Self, uri_template: str) -> str: return self._parse_uri(self, uri_template) + + def _validate_factory( + self: Self, elements: Sequence[T2], properties: Mapping[str, str] + ) -> List[T2]: + validated_elements: List[T2] = [] + for element_factory in elements: + if not callable(element_factory): + validated_elements.append(element_factory) + continue + element = element_factory(self._app, properties) + if not element: + continue + validated_elements.append(element) + return validated_elements diff --git a/fastapi_hypermodel/url_type.py b/fastapi_hypermodel/base/url_type.py similarity index 100% rename from fastapi_hypermodel/url_type.py rename to fastapi_hypermodel/base/url_type.py diff --git a/fastapi_hypermodel/utils.py b/fastapi_hypermodel/base/utils.py similarity index 100% rename from fastapi_hypermodel/utils.py rename to fastapi_hypermodel/base/utils.py diff --git a/fastapi_hypermodel/hal.py b/fastapi_hypermodel/hal.py deleted file mode 100644 index d847d83..0000000 --- a/fastapi_hypermodel/hal.py +++ /dev/null @@ -1,312 +0,0 @@ -from collections import defaultdict -from itertools import chain -from typing import ( - Any, - Callable, - ClassVar, - Dict, - List, - Mapping, - Optional, - Sequence, - Type, - Union, -) - -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator -from starlette.applications import Starlette -from starlette.responses import JSONResponse -from starlette.routing import Route -from typing_extensions import Self - -from fastapi_hypermodel.hypermodel import AbstractHyperField, HasName, HyperModel -from fastapi_hypermodel.linkset import LinkSetType -from fastapi_hypermodel.url_type import UrlType -from fastapi_hypermodel.utils import get_route_from_app, resolve_param_values - - -class HALForType(BaseModel): - href: UrlType = Field(default=UrlType()) - templated: Optional[bool] = None - title: Optional[str] = None - name: Optional[str] = None - type: Optional[str] = None - hreflang: Optional[str] = None - profile: Optional[str] = None - deprecation: Optional[str] = None - - def __bool__(self: Self) -> bool: - return bool(self.href) - - -class HALFor(HALForType, AbstractHyperField[HALForType]): - # pylint: disable=too-many-instance-attributes - _endpoint: str = PrivateAttr() - _param_values: Mapping[str, str] = PrivateAttr() - _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() - _templated: Optional[bool] = PrivateAttr() - # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal - _title: Optional[str] = PrivateAttr() - _name: Optional[str] = PrivateAttr() - _type: Optional[str] = PrivateAttr() - _hreflang: Optional[str] = PrivateAttr() - _profile: Optional[str] = PrivateAttr() - _deprecation: Optional[str] = PrivateAttr() - - def __init__( - self: Self, - endpoint: Union[HasName, str], - param_values: Optional[Mapping[str, str]] = None, - description: Optional[str] = None, - condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, - templated: Optional[bool] = None, - title: Optional[str] = None, - name: Optional[str] = None, - type_: Optional[str] = None, - hreflang: Optional[str] = None, - profile: Optional[str] = None, - deprecation: Optional[str] = None, - ) -> None: - super().__init__() - self._endpoint = ( - endpoint.__name__ if isinstance(endpoint, HasName) else endpoint - ) - self._param_values = param_values or {} - self._description = description - self._condition = condition - self._templated = templated - self._title = title - self._name = name - self._type = type_ - self._hreflang = hreflang - self._profile = profile - self._deprecation = deprecation - - def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] - ) -> UrlType: - if self._templated and isinstance(route, Route): - return UrlType(route.path) - - params = resolve_param_values(self._param_values, values) - return UrlType(app.url_path_for(self._endpoint, **params)) - - def __call__( - self: Self, app: Optional[Starlette], values: Mapping[str, Any] - ) -> HALForType: - if app is None: - return HALForType() - - if self._condition and not self._condition(values): - return HALForType() - - route = get_route_from_app(app, self._endpoint) - - uri_path = self._get_uri_path(app, values, route) - - return HALForType( - href=uri_path, - templated=self._templated, - title=self._title, - name=self._name, - type=self._type, - hreflang=self._hreflang, - profile=self._profile, - deprecation=self._deprecation, - ) - - -class HalHyperModel(HyperModel): - curies_: ClassVar[Optional[Sequence[HALForType]]] = None - embedded: Mapping[str, Union[Self, Sequence[Self]]] = Field( - default=None, alias="_embedded" - ) - - # This config is needed to use the Self in Embedded - model_config = ConfigDict(arbitrary_types_allowed=True) - - @classmethod - def register_curies(cls: Type[Self], curies: Sequence[HALForType]) -> None: - cls.curies_ = curies - - @classmethod - def curies(cls: Type[Self]) -> Sequence[HALForType]: - return cls.curies_ or [] - - @model_validator(mode="after") - def add_curies_to_links(self: Self) -> Self: - for _, value in self: - if not isinstance(value, LinkSetType): - continue - value.mapping["curies"] = HalHyperModel.curies() # type: ignore - - return self - - @model_validator(mode="after") - def add_hypermodels_to_embedded(self: Self) -> Self: - embedded: Dict[str, Union[Self, Sequence[Self]]] = {} - for name, field in self: - value: Sequence[Union[Any, Self]] = ( - field if isinstance(field, Sequence) else [field] - ) - - if not all(isinstance(element, HalHyperModel) for element in value): - continue - - key = self.model_fields[name].alias or name - embedded[key] = value - delattr(self, name) - - self.embedded = embedded - - if not self.embedded: - delattr(self, "embedded") - - return self - - -EmbeddedRawType = Union[Mapping[str, Union[Sequence[Any], Any]], Any] -LinksRawType = Union[Mapping[str, Union[Any, Sequence[Any]]], Any] - - -class HALResponse(JSONResponse): - media_type = "application/hal+json" - - @staticmethod - def _validate_embedded( - content: Any, - ) -> Dict[str, List[Any]]: - embedded: EmbeddedRawType = content.get("_embedded") - - if embedded is None: - return {} - - if not embedded: - error_message = "If embedded is specified it must not be empty" - raise TypeError(error_message) - - if not isinstance(embedded, Mapping): - error_message = "Embedded must be a mapping" - raise TypeError(error_message) - - validated_embedded: Dict[str, List[Any]] = defaultdict(list) - for name, embedded_ in embedded.items(): - embedded_sequence = ( - embedded_ if isinstance(embedded_, Sequence) else [embedded_] - ) - validated_embedded[name].extend(embedded_sequence) - - return validated_embedded - - @staticmethod - def _validate_links(content: Any) -> Dict[str, List[HALForType]]: - links: LinksRawType = content.get("_links") - - if links is None: - return {} - - if not isinstance(links, Mapping): - error_message = "Links must be a Mapping" - raise TypeError(error_message) - - self_link_raw = links.get("self") - - if not self_link_raw: - error_message = "If _links is present, self link must be specified" - raise TypeError(error_message) - - self_link = HALForType.model_validate(self_link_raw) - - if self_link.templated: - error_message = "Self link must not be templated" - raise TypeError(error_message) - - if not self_link.href: - error_message = "Self link must have non-empty href" - raise TypeError(error_message) - - if not all(name for name in links): - error_message = "All Links must have non-empty names" - raise TypeError(error_message) - - validated_links: Dict[str, List[HALForType]] = defaultdict(list) - for name, links_ in links.items(): - link_sequence = links_ if isinstance(links_, Sequence) else [links_] - hal_for_type = [HALForType.model_validate(link_) for link_ in link_sequence] - validated_links[name].extend(hal_for_type) - - return validated_links - - @staticmethod - def _extract_curies( - links: Mapping[str, Sequence[HALForType]], - ) -> Sequence[HALForType]: - curies = links.get("curies") - - if curies is None: - return [] - - for link in curies: - if not link.templated: - error_message = "Curies must be templated" - raise TypeError(error_message) - - if not link.name: - error_message = "Curies must have a name" - raise TypeError(error_message) - - if not link.href: - error_message = "Curies must have href" - raise TypeError(error_message) - - key_in_template = "rel" - if key_in_template not in link.href: - error_message = "Curies must be have 'rel' parameter in href" - raise TypeError(error_message) - - return curies - - @staticmethod - def _validate_name_in_curies(curies: Sequence[HALForType], name: str) -> None: - expected_name, separator, _ = name.partition(":") - if not separator: - return - - curie_names = [curie.name for curie in curies] - if not curie_names: - error_message = "CURIEs were used but none was specified" - raise TypeError(error_message) - - if any(expected_name == name for name in curie_names): - return - - error_message = f"No CURIE found for '{expected_name}' in _links" - raise TypeError(error_message) - - def _validate( - self: Self, content: Any, parent_curies: Optional[Sequence[HALForType]] = None - ) -> None: - if not content: - return - - parent_curies = parent_curies or [] - - links = self._validate_links(content) - curies = self._extract_curies(links) - combined_curies = list(chain(curies, parent_curies)) - - for link_name in links: - self._validate_name_in_curies(combined_curies, link_name) - - embedded = self._validate_embedded(content) - - for embedded_name in embedded: - self._validate_name_in_curies(combined_curies, embedded_name) - - for embedded_ in embedded.values(): - for element in embedded_: - self._validate(element, parent_curies=combined_curies) - - def render(self: Self, content: Any) -> bytes: - self._validate(content) - return super().render(content) diff --git a/fastapi_hypermodel/hal/__init__.py b/fastapi_hypermodel/hal/__init__.py new file mode 100644 index 0000000..ba0fa11 --- /dev/null +++ b/fastapi_hypermodel/hal/__init__.py @@ -0,0 +1,10 @@ +from .hal_hypermodel import HALFor, HALForType, HalHyperModel, HALLinkType +from .hal_response import HALResponse + +__all__ = [ + "HALFor", + "HALForType", + "HALLinkType", + "HALResponse", + "HalHyperModel", +] diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py new file mode 100644 index 0000000..cd39da0 --- /dev/null +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -0,0 +1,191 @@ +from typing import ( + Any, + Callable, + ClassVar, + Dict, + Mapping, + Optional, + Sequence, + Type, + Union, + cast, +) + +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator +from starlette.applications import Starlette +from starlette.routing import Route +from typing_extensions import Self + +from fastapi_hypermodel.base import ( + AbstractHyperField, + HasName, + HyperModel, + UrlType, + get_route_from_app, + resolve_param_values, +) + + +class HALForType(BaseModel): + href: UrlType = Field(default=UrlType()) + templated: Optional[bool] = None + title: Optional[str] = None + name: Optional[str] = None + type: Optional[str] = None + hreflang: Optional[str] = None + profile: Optional[str] = None + deprecation: Optional[str] = None + + def __bool__(self: Self) -> bool: + return bool(self.href) + + +class HALFor(HALForType, AbstractHyperField[HALForType]): + # pylint: disable=too-many-instance-attributes + _endpoint: str = PrivateAttr() + _param_values: Mapping[str, str] = PrivateAttr() + _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() + _templated: Optional[bool] = PrivateAttr() + # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal + _title: Optional[str] = PrivateAttr() + _name: Optional[str] = PrivateAttr() + _type: Optional[str] = PrivateAttr() + _hreflang: Optional[str] = PrivateAttr() + _profile: Optional[str] = PrivateAttr() + _deprecation: Optional[str] = PrivateAttr() + + def __init__( + self: Self, + endpoint: Union[HasName, str], + param_values: Optional[Mapping[str, str]] = None, + description: Optional[str] = None, + condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, + templated: Optional[bool] = None, + title: Optional[str] = None, + name: Optional[str] = None, + type_: Optional[str] = None, + hreflang: Optional[str] = None, + profile: Optional[str] = None, + deprecation: Optional[str] = None, + ) -> None: + super().__init__() + self._endpoint = ( + endpoint.__name__ if isinstance(endpoint, HasName) else endpoint + ) + self._param_values = param_values or {} + self._description = description + self._condition = condition + self._templated = templated + self._title = title + self._name = name + self._type = type_ + self._hreflang = hreflang + self._profile = profile + self._deprecation = deprecation + + def _get_uri_path( + self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] + ) -> UrlType: + if self._templated and isinstance(route, Route): + return UrlType(route.path) + + params = resolve_param_values(self._param_values, values) + return UrlType(app.url_path_for(self._endpoint, **params)) + + def __call__( + self: Self, app: Optional[Starlette], values: Mapping[str, Any] + ) -> HALForType: + if app is None: + return HALForType() + + if self._condition and not self._condition(values): + return HALForType() + + route = get_route_from_app(app, self._endpoint) + + uri_path = self._get_uri_path(app, values, route) + + return HALForType( + href=uri_path, + templated=self._templated, + title=self._title, + name=self._name, + type=self._type, + hreflang=self._hreflang, + profile=self._profile, + deprecation=self._deprecation, + ) + + +HALLinkType = Union[HALFor, Sequence[HALFor]] + + +class HalHyperModel(HyperModel): + curies_: ClassVar[Optional[Sequence[HALForType]]] = None + links: Dict[str, HALLinkType] = Field(default_factory=dict, alias="_links") + embedded: Mapping[str, Union[Self, Sequence[Self]]] = Field( + default_factory=dict, alias="_embedded" + ) + + # This config is needed to use the Self in Embedded + model_config = ConfigDict(arbitrary_types_allowed=True) + + @classmethod + def register_curies(cls: Type[Self], curies: Sequence[HALForType]) -> None: + cls.curies_ = curies + + @classmethod + def curies(cls: Type[Self]) -> Sequence[HALForType]: + return cls.curies_ or [] + + @model_validator(mode="after") + def add_links(self: Self) -> Self: + links_key = "_links" + for name, value in self: + alias = self.model_fields[name].alias or name + + if alias != links_key or not value: + continue + + links = cast(Mapping[str, HALLinkType], value) + for link_name, link_ in links.items(): + is_sequence = isinstance(link_, Sequence) + + link_sequence: Sequence[HALFor] = link_ if is_sequence else [link_] + valid_links = self._validate_factory(link_sequence, vars(self)) + + if not valid_links: + continue + + first_link, *_ = valid_links + self.links[link_name] = valid_links if is_sequence else first_link + + self.links["curies"] = HalHyperModel.curies() # type: ignore + + return self + + @model_validator(mode="after") + def add_hypermodels_to_embedded(self: Self) -> Self: + embedded: Dict[str, Union[Self, Sequence[Self]]] = {} + for name, field in self: + value: Sequence[Union[Any, Self]] = ( + field if isinstance(field, Sequence) else [field] + ) + + if not all(isinstance(element, HalHyperModel) for element in value): + continue + + key = self.model_fields[name].alias or name + embedded[key] = value + delattr(self, name) + + self.embedded = embedded + + if not self.embedded: + delattr(self, "embedded") + + return self + + +EmbeddedRawType = Union[Mapping[str, Union[Sequence[Any], Any]], Any] +LinksRawType = Union[Mapping[str, Union[Any, Sequence[Any]]], Any] diff --git a/fastapi_hypermodel/hal/hal_response.py b/fastapi_hypermodel/hal/hal_response.py new file mode 100644 index 0000000..642bdc0 --- /dev/null +++ b/fastapi_hypermodel/hal/hal_response.py @@ -0,0 +1,162 @@ +from collections import defaultdict +from itertools import chain +from typing import ( + Any, + Dict, + List, + Mapping, + Optional, + Sequence, + Union, +) + +from starlette.responses import JSONResponse +from typing_extensions import Self + +from .hal_hypermodel import HALForType + +EmbeddedRawType = Union[Mapping[str, Union[Sequence[Any], Any]], Any] +LinksRawType = Union[Mapping[str, Union[Any, Sequence[Any]]], Any] + + +class HALResponse(JSONResponse): + media_type = "application/hal+json" + + @staticmethod + def _validate_embedded( + content: Any, + ) -> Dict[str, List[Any]]: + embedded: EmbeddedRawType = content.get("_embedded") + + if embedded is None: + return {} + + if not embedded: + error_message = "If embedded is specified it must not be empty" + raise TypeError(error_message) + + if not isinstance(embedded, Mapping): + error_message = "Embedded must be a mapping" + raise TypeError(error_message) + + validated_embedded: Dict[str, List[Any]] = defaultdict(list) + for name, embedded_ in embedded.items(): + embedded_sequence = ( + embedded_ if isinstance(embedded_, Sequence) else [embedded_] + ) + validated_embedded[name].extend(embedded_sequence) + + return validated_embedded + + @staticmethod + def _validate_links(content: Any) -> Dict[str, List[HALForType]]: + links: LinksRawType = content.get("_links") + + if links is None: + return {} + + if not isinstance(links, Mapping): + error_message = "Links must be a Mapping" + raise TypeError(error_message) + + self_link_raw = links.get("self") + + if not self_link_raw: + error_message = "If _links is present, self link must be specified" + raise TypeError(error_message) + + self_link = HALForType.model_validate(self_link_raw) + + if self_link.templated: + error_message = "Self link must not be templated" + raise TypeError(error_message) + + if not self_link.href: + error_message = "Self link must have non-empty href" + raise TypeError(error_message) + + if not all(name for name in links): + error_message = "All Links must have non-empty names" + raise TypeError(error_message) + + validated_links: Dict[str, List[HALForType]] = defaultdict(list) + for name, links_ in links.items(): + link_sequence = links_ if isinstance(links_, Sequence) else [links_] + hal_for_type = [HALForType.model_validate(link_) for link_ in link_sequence] + validated_links[name].extend(hal_for_type) + + return validated_links + + @staticmethod + def _extract_curies( + links: Mapping[str, Sequence[HALForType]], + ) -> Sequence[HALForType]: + curies = links.get("curies") + + if curies is None: + return [] + + for link in curies: + if not link.templated: + error_message = "Curies must be templated" + raise TypeError(error_message) + + if not link.name: + error_message = "Curies must have a name" + raise TypeError(error_message) + + if not link.href: + error_message = "Curies must have href" + raise TypeError(error_message) + + key_in_template = "rel" + if key_in_template not in link.href: + error_message = "Curies must be have 'rel' parameter in href" + raise TypeError(error_message) + + return curies + + @staticmethod + def _validate_name_in_curies(curies: Sequence[HALForType], name: str) -> None: + expected_name, separator, _ = name.partition(":") + if not separator: + return + + curie_names = [curie.name for curie in curies] + if not curie_names: + error_message = "CURIEs were used but none was specified" + raise TypeError(error_message) + + if any(expected_name == name for name in curie_names): + return + + error_message = f"No CURIE found for '{expected_name}' in _links" + raise TypeError(error_message) + + def _validate( + self: Self, content: Any, parent_curies: Optional[Sequence[HALForType]] = None + ) -> None: + if not content: + return + + parent_curies = parent_curies or [] + + links = self._validate_links(content) + curies = self._extract_curies(links) + combined_curies = list(chain(curies, parent_curies)) + + for link_name in links: + self._validate_name_in_curies(combined_curies, link_name) + + embedded = self._validate_embedded(content) + + for embedded_name in embedded: + self._validate_name_in_curies(combined_curies, embedded_name) + + for embedded_ in embedded.values(): + for element in embedded_: + self._validate(element, parent_curies=combined_curies) + + def render(self: Self, content: Any) -> bytes: + self._validate(content) + return super().render(content) diff --git a/fastapi_hypermodel/siren.py b/fastapi_hypermodel/siren.py deleted file mode 100644 index 6cc787b..0000000 --- a/fastapi_hypermodel/siren.py +++ /dev/null @@ -1,500 +0,0 @@ -from __future__ import annotations - -from itertools import starmap -from typing import ( - Any, - Callable, - Dict, - List, - Mapping, - Sequence, - Type, - TypeVar, - Union, - cast, -) - -import jsonschema -from fastapi.routing import APIRoute -from pydantic import ( - BaseModel, - ConfigDict, - Field, - PrivateAttr, - field_validator, - model_serializer, - model_validator, -) -from pydantic.fields import FieldInfo -from starlette.applications import Starlette -from starlette.responses import JSONResponse -from starlette.routing import Route -from typing_extensions import Self - -from fastapi_hypermodel.hypermodel import AbstractHyperField, HasName, HyperModel -from fastapi_hypermodel.url_type import UrlType -from fastapi_hypermodel.utils import ( - get_route_from_app, - resolve_param_values, -) - -from .siren_schema import schema - - -class SirenBase(BaseModel): - class_: Union[Sequence[str], None] = Field(default=None, alias="class") - title: Union[str, None] = Field(default=None) - - @model_serializer - def serialize(self: Self) -> Mapping[str, Any]: - return {self.model_fields[k].alias or k: v for k, v in self if v} - - -class SirenLinkType(SirenBase): - rel: Sequence[str] = Field(default_factory=list) - href: UrlType = Field(default=UrlType()) - type_: Union[str, None] = Field(default=None, alias="type") - - @field_validator("rel", "href") - @classmethod - def mandatory(cls: Type[Self], value: Union[str, None]) -> str: - if not value: - error_message = "Field rel and href are mandatory" - raise ValueError(error_message) - return value - - -class SirenLinkFor(SirenLinkType, AbstractHyperField[SirenLinkType]): - # pylint: disable=too-many-instance-attributes - _endpoint: str = PrivateAttr() - _param_values: Mapping[str, str] = PrivateAttr() - _templated: bool = PrivateAttr() - _condition: Union[Callable[[Mapping[str, Any]], bool], None] = PrivateAttr() - - # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal - _title: Union[str, None] = PrivateAttr() - _type: Union[str, None] = PrivateAttr() - _rel: Sequence[str] = PrivateAttr() - _class: Union[Sequence[str], None] = PrivateAttr() - - def __init__( - self: Self, - endpoint: Union[HasName, str], - param_values: Union[Mapping[str, str], None] = None, - templated: bool = False, - condition: Union[Callable[[Mapping[str, Any]], bool], None] = None, - title: Union[str, None] = None, - type_: Union[str, None] = None, - rel: Union[Sequence[str], None] = None, - class_: Union[Sequence[str], None] = None, - **kwargs: Any, - ) -> None: - super().__init__(**kwargs) - self._endpoint = ( - endpoint.__name__ if isinstance(endpoint, HasName) else endpoint - ) - self._param_values = param_values or {} - self._templated = templated - self._condition = condition - self._title = title - self._type = type_ - self._rel = rel or [] - self._class = class_ - - def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] - ) -> UrlType: - if self._templated and isinstance(route, Route): - return UrlType(route.path) - - params = resolve_param_values(self._param_values, values) - return UrlType(app.url_path_for(self._endpoint, **params)) - - def __call__( - self: Self, app: Union[Starlette, None], values: Mapping[str, Any] - ) -> Union[SirenLinkType, None]: - if app is None: - return None - - if self._condition and not self._condition(values): - return None - - route = get_route_from_app(app, self._endpoint) - - properties = values.get("properties", values) - uri_path = self._get_uri_path(app, properties, route) - - # Using model_validate to avoid conflicts with keyword class - return SirenLinkType.model_validate({ - "href": uri_path, - "rel": self._rel, - "title": self._title, - "type": self._type, - "class": self._class, - }) - - -class SirenFieldType(SirenBase): - name: str - type_: Union[str, None] = Field(default=None, alias="type") - value: Union[Any, None] = None - - @classmethod - def from_field_info(cls: Type[Self], name: str, field_info: FieldInfo) -> Self: - return cls.model_validate({ - "name": name, - "type": cls.parse_type(field_info.annotation), - "value": field_info.default, - }) - - @staticmethod - def parse_type(python_type: Union[Type[Any], None]) -> str: - type_repr = repr(python_type) - - text_types = ("str",) - if any(text_type in type_repr for text_type in text_types): - return "text" - - number_types = ("float", "int") - if any(number_type in type_repr for number_type in number_types): - return "number" - - return "text" - - -class SirenActionType(SirenBase): - name: str = Field(default="") - method: str = Field(default="GET") - href: UrlType = Field(default=UrlType()) - type_: Union[str, None] = Field(default=None, alias="type") - fields: Union[Sequence[SirenFieldType], None] = Field(default=None) - templated: bool = Field(default=False) - - @field_validator("name", "href") - @classmethod - def mandatory(cls: Type[Self], value: Union[str, None]) -> str: - if not value: - error_message = f"Field name and href are mandatory, {value}" - raise ValueError(error_message) - return value - - -class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): # pylint: disable=too-many-instance-attributes - _endpoint: str = PrivateAttr() - _param_values: Mapping[str, str] = PrivateAttr() - _templated: bool = PrivateAttr() - _condition: Union[Callable[[Mapping[str, Any]], bool], None] = PrivateAttr() - _populate_fields: bool = PrivateAttr() - - # For details on the folllowing fields, check https://github.com/kevinswiber/siren - _class: Union[Sequence[str], None] = PrivateAttr() - _title: Union[str, None] = PrivateAttr() - _name: Union[str, None] = PrivateAttr() - _method: Union[str, None] = PrivateAttr() - _type: Union[str, None] = PrivateAttr() - _fields: Union[Sequence[SirenFieldType], None] = PrivateAttr() - - def __init__( - self: Self, - endpoint: Union[HasName, str], - param_values: Union[Mapping[str, str], None] = None, - templated: bool = False, - condition: Union[Callable[[Mapping[str, Any]], bool], None] = None, - populate_fields: bool = True, - title: Union[str, None] = None, - type_: Union[str, None] = None, - class_: Union[Sequence[str], None] = None, - fields: Union[Sequence[SirenFieldType], None] = None, - method: Union[str, None] = None, - name: Union[str, None] = "", - **kwargs: Any, - ) -> None: - super().__init__(**kwargs) - self._endpoint = ( - endpoint.__name__ if isinstance(endpoint, HasName) else endpoint - ) - self._param_values = param_values or {} - self._templated = templated - self._condition = condition - self._populate_fields = populate_fields - self._title = title - self._type = type_ - self._fields = fields or [] - self._method = method - self._name = name - self._class = class_ - - def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] - ) -> UrlType: - if self._templated and isinstance(route, Route): - return UrlType(route.path) - - params = resolve_param_values(self._param_values, values) - return UrlType(app.url_path_for(self._endpoint, **params)) - - def _prepopulate_fields( - self: Self, fields: Sequence[SirenFieldType], values: Mapping[str, Any] - ) -> List[SirenFieldType]: - if not self._populate_fields: - return list(fields) - - for field in fields: - value = values.get(field.name) or field.value - field.value = str(value) - return list(fields) - - def _compute_fields( - self: Self, route: Route, values: Mapping[str, Any] - ) -> List[SirenFieldType]: - if not isinstance(route, APIRoute): # pragma: no cover - route.body_field = "" # type: ignore - route = cast(APIRoute, route) - - body_field = route.body_field - if not body_field: - return [] - - annotation: Any = body_field.field_info.annotation or {} - model_fields: Any = annotation.model_fields if annotation else {} - model_fields = cast(Dict[str, FieldInfo], model_fields) - - fields = list(starmap(SirenFieldType.from_field_info, model_fields.items())) - return self._prepopulate_fields(fields, values) - - def __call__( - self: Self, app: Union[Starlette, None], values: Mapping[str, Any] - ) -> Union[SirenActionType, None]: - if app is None: - return None - - if self._condition and not self._condition(values): - return None - - route = get_route_from_app(app, self._endpoint) - - if not self._method: - self._method = next(iter(route.methods or {}), None) - - uri_path = self._get_uri_path(app, values, route) - - if not self._fields: - self._fields = self._compute_fields(route, values) - - if not self._type and self._fields: - self._type = "application/x-www-form-urlencoded" - - # Using model_validate to avoid conflicts with class and type - return SirenActionType.model_validate({ - "href": uri_path, - "name": self._name, - "fields": self._fields, - "method": self._method, - "title": self._title, - "type": self._type, - "class": self._class, - "templated": self._templated, - }) - - -class SirenEntityType(SirenBase): - properties: Union[Mapping[str, Any], None] = None - entities: Union[Sequence[Union[SirenEmbeddedType, SirenLinkType]], None] = None - links: Union[Sequence[SirenLinkType], None] = None - actions: Union[Sequence[SirenActionType], None] = None - - -class SirenEmbeddedType(SirenEntityType): - rel: Sequence[str] = Field() - - -T = TypeVar("T", bound=Callable[..., Any]) - -SIREN_RESERVED_FIELDS = { - "properties", - "entities", - "links", - "actions", -} - - -class SirenHyperModel(HyperModel): - properties: Dict[str, Any] = Field(default_factory=dict) - entities: Sequence[Union[SirenEmbeddedType, SirenLinkType]] = Field( - default_factory=list - ) - links: Sequence[SirenLinkFor] = Field(default_factory=list) - actions: Sequence[SirenActionFor] = Field(default_factory=list) - - # This config is needed to use the Self in Embedded - model_config = ConfigDict(arbitrary_types_allowed=True) - - @model_validator(mode="after") - def add_hypermodels_to_entities(self: Self) -> Self: - entities: List[Union[SirenEmbeddedType, SirenLinkType]] = [] - for name, field in self: - alias = self.model_fields[name].alias or name - - if alias in SIREN_RESERVED_FIELDS: - continue - - value: Sequence[Union[Any, Self]] = ( - field if isinstance(field, Sequence) else [field] - ) - - if not all( - isinstance(element, (SirenHyperModel, SirenLinkType)) - for element in value - ): - continue - - for field_ in value: - if isinstance(field_, SirenLinkType): - entities.append(field_) - continue - - child = self.as_embedded(field_, alias) - entities.append(child) - - delattr(self, name) - - self.entities = entities - - return self - - @model_validator(mode="after") - def add_properties(self: Self) -> Self: - properties = {} - for name, field in self: - alias = self.model_fields[name].alias or name - - if alias in SIREN_RESERVED_FIELDS: - continue - - value: Sequence[Any] = field if isinstance(field, Sequence) else [field] - - omit_types: Any = ( - AbstractHyperField, - SirenLinkFor, - SirenLinkType, - SirenActionFor, - SirenActionType, - SirenHyperModel, - ) - if any(isinstance(value_, omit_types) for value_ in value): - continue - - properties[alias] = value if isinstance(field, Sequence) else field - - delattr(self, name) - - if not self.properties: - self.properties = {} - - self.properties.update(properties) - - return self - - @model_validator(mode="after") - def add_links(self: Self) -> Self: - links_key = "links" - validated_links: List[SirenLinkFor] = [] - for name, value in self: - alias = self.model_fields[name].alias or name - - if alias != links_key or not value: - continue - - links = cast(Sequence[SirenLinkFor], value) - properties = self.properties or {} - validated_links = self._validate_factory(links, properties) - self.links = validated_links - - self.validate_has_self_link(validated_links) - - return self - - @staticmethod - def validate_has_self_link(links: Sequence[SirenLinkFor]) -> None: - if not links: - return - - if any(link.rel == ["self"] for link in links): - return - - error_message = "If links are present, a link with rel self must be present" - raise ValueError(error_message) - - @model_validator(mode="after") - def add_actions(self: Self) -> Self: - actions_key = "actions" - for name, value in self: - alias = self.model_fields[name].alias or name - - if alias != actions_key or not value: - continue - - properties = self.properties or {} - actions = cast(Sequence[SirenActionFor], value) - self.actions = self._validate_factory(actions, properties) - - return self - - def _validate_factory( - self: Self, elements: Sequence[T], properties: Mapping[str, str] - ) -> List[T]: - validated_elements: List[T] = [] - for element_factory in elements: - element = element_factory(self._app, properties) - if not element: - continue - validated_elements.append(element) - return validated_elements - - @model_validator(mode="after") - def no_action_outside_of_actions(self: Self) -> Self: - for _, field in self: - if not isinstance(field, (SirenActionFor, SirenActionType)): - continue - - error_message = "All actions must be inside the actions property" - raise ValueError(error_message) - - return self - - @model_serializer - def serialize(self: Self) -> Mapping[str, Any]: - return {self.model_fields[k].alias or k: v for k, v in self if v} - - @staticmethod - def as_embedded(field: SirenHyperModel, rel: str) -> SirenEmbeddedType: - return SirenEmbeddedType(rel=[rel], **field.model_dump()) - - def parse_uri(self: Self, uri_template: str) -> str: - return self._parse_uri(self.properties, uri_template) - - -class SirenResponse(JSONResponse): - media_type = "application/siren+json" - - @staticmethod - def _validate(content: Any) -> None: - jsonschema.validate(instance=content, schema=schema) - - def render(self: Self, content: Any) -> bytes: - self._validate(content) - return super().render(content) - - -def get_siren_link(response: Any, link_name: str) -> Union[SirenLinkType, None]: - links = response.get("links", []) - link = next((link for link in links if link_name in link.get("rel")), None) - return SirenLinkType.model_validate(link) if link else None - - -def get_siren_action(response: Any, action_name: str) -> Union[SirenActionType, None]: - actions = response.get("actions", []) - action = next( - (action for action in actions if action_name in action.get("name")), None - ) - return SirenActionType.model_validate(action) if action else None diff --git a/fastapi_hypermodel/siren/__init__.py b/fastapi_hypermodel/siren/__init__.py new file mode 100644 index 0000000..f49587d --- /dev/null +++ b/fastapi_hypermodel/siren/__init__.py @@ -0,0 +1,33 @@ +from .siren_action import ( + SirenActionFor, + SirenActionType, +) +from .siren_field import ( + SirenFieldType, +) +from .siren_hypermodel import ( + SirenEmbeddedType, + SirenHyperModel, +) +from .siren_link import ( + SirenLinkFor, + SirenLinkType, +) +from .siren_response import ( + SirenResponse, + get_siren_action, + get_siren_link, +) + +__all__ = [ + "SirenActionFor", + "SirenActionType", + "SirenEmbeddedType", + "SirenFieldType", + "SirenHyperModel", + "SirenLinkFor", + "SirenLinkType", + "SirenResponse", + "get_siren_action", + "get_siren_link", +] diff --git a/fastapi_hypermodel/siren/siren_action.py b/fastapi_hypermodel/siren/siren_action.py new file mode 100644 index 0000000..5948ee7 --- /dev/null +++ b/fastapi_hypermodel/siren/siren_action.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from itertools import starmap +from typing import ( + Any, + Callable, + Dict, + List, + Mapping, + Sequence, + Type, + Union, + cast, +) + +from fastapi.routing import APIRoute +from pydantic import ( + Field, + PrivateAttr, + field_validator, +) +from pydantic.fields import FieldInfo +from starlette.applications import Starlette +from starlette.routing import Route +from typing_extensions import Self + +from fastapi_hypermodel.base import ( + AbstractHyperField, + HasName, + UrlType, + get_route_from_app, + resolve_param_values, +) + +from .siren_base import SirenBase +from .siren_field import SirenFieldType + + +class SirenActionType(SirenBase): + name: str = Field(default="") + method: str = Field(default="GET") + href: UrlType = Field(default=UrlType()) + type_: Union[str, None] = Field(default=None, alias="type") + fields: Union[Sequence[SirenFieldType], None] = Field(default=None) + templated: bool = Field(default=False) + + @field_validator("name", "href") + @classmethod + def mandatory(cls: Type[Self], value: Union[str, None]) -> str: + if not value: + error_message = f"Field name and href are mandatory, {value}" + raise ValueError(error_message) + return value + + +class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): # pylint: disable=too-many-instance-attributes + _endpoint: str = PrivateAttr() + _param_values: Mapping[str, str] = PrivateAttr() + _templated: bool = PrivateAttr() + _condition: Union[Callable[[Mapping[str, Any]], bool], None] = PrivateAttr() + _populate_fields: bool = PrivateAttr() + + # For details on the folllowing fields, check https://github.com/kevinswiber/siren + _class: Union[Sequence[str], None] = PrivateAttr() + _title: Union[str, None] = PrivateAttr() + _name: Union[str, None] = PrivateAttr() + _method: Union[str, None] = PrivateAttr() + _type: Union[str, None] = PrivateAttr() + _fields: Union[Sequence[SirenFieldType], None] = PrivateAttr() + + def __init__( + self: Self, + endpoint: Union[HasName, str], + param_values: Union[Mapping[str, str], None] = None, + templated: bool = False, + condition: Union[Callable[[Mapping[str, Any]], bool], None] = None, + populate_fields: bool = True, + title: Union[str, None] = None, + type_: Union[str, None] = None, + class_: Union[Sequence[str], None] = None, + fields: Union[Sequence[SirenFieldType], None] = None, + method: Union[str, None] = None, + name: Union[str, None] = "", + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._endpoint = ( + endpoint.__name__ if isinstance(endpoint, HasName) else endpoint + ) + self._param_values = param_values or {} + self._templated = templated + self._condition = condition + self._populate_fields = populate_fields + self._title = title + self._type = type_ + self._fields = fields or [] + self._method = method + self._name = name + self._class = class_ + + def _get_uri_path( + self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] + ) -> UrlType: + if self._templated and isinstance(route, Route): + return UrlType(route.path) + + params = resolve_param_values(self._param_values, values) + return UrlType(app.url_path_for(self._endpoint, **params)) + + def _prepopulate_fields( + self: Self, fields: Sequence[SirenFieldType], values: Mapping[str, Any] + ) -> List[SirenFieldType]: + if not self._populate_fields: + return list(fields) + + for field in fields: + value = values.get(field.name) or field.value + field.value = str(value) + return list(fields) + + def _compute_fields( + self: Self, route: Route, values: Mapping[str, Any] + ) -> List[SirenFieldType]: + if not isinstance(route, APIRoute): # pragma: no cover + route.body_field = "" # type: ignore + route = cast(APIRoute, route) + + body_field = route.body_field + if not body_field: + return [] + + annotation: Any = body_field.field_info.annotation or {} + model_fields: Any = annotation.model_fields if annotation else {} + model_fields = cast(Dict[str, FieldInfo], model_fields) + + fields = list(starmap(SirenFieldType.from_field_info, model_fields.items())) + return self._prepopulate_fields(fields, values) + + def __call__( + self: Self, app: Union[Starlette, None], values: Mapping[str, Any] + ) -> Union[SirenActionType, None]: + if app is None: + return None + + if self._condition and not self._condition(values): + return None + + route = get_route_from_app(app, self._endpoint) + + if not self._method: + self._method = next(iter(route.methods or {}), None) + + uri_path = self._get_uri_path(app, values, route) + + if not self._fields: + self._fields = self._compute_fields(route, values) + + if not self._type and self._fields: + self._type = "application/x-www-form-urlencoded" + + # Using model_validate to avoid conflicts with class and type + return SirenActionType.model_validate({ + "href": uri_path, + "name": self._name, + "fields": self._fields, + "method": self._method, + "title": self._title, + "type": self._type, + "class": self._class, + "templated": self._templated, + }) diff --git a/fastapi_hypermodel/siren/siren_base.py b/fastapi_hypermodel/siren/siren_base.py new file mode 100644 index 0000000..fd459f7 --- /dev/null +++ b/fastapi_hypermodel/siren/siren_base.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import ( + Any, + Mapping, + Sequence, + Union, +) + +from pydantic import ( + BaseModel, + Field, + model_serializer, +) +from typing_extensions import Self + + +class SirenBase(BaseModel): + class_: Union[Sequence[str], None] = Field(default=None, alias="class") + title: Union[str, None] = Field(default=None) + + @model_serializer + def serialize(self: Self) -> Mapping[str, Any]: + return {self.model_fields[k].alias or k: v for k, v in self if v} diff --git a/fastapi_hypermodel/siren/siren_field.py b/fastapi_hypermodel/siren/siren_field.py new file mode 100644 index 0000000..37d9bb6 --- /dev/null +++ b/fastapi_hypermodel/siren/siren_field.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import ( + Any, + Type, + Union, +) + +from pydantic import ( + Field, +) +from pydantic.fields import FieldInfo +from typing_extensions import Self + +from .siren_base import SirenBase + + +class SirenFieldType(SirenBase): + name: str + type_: Union[str, None] = Field(default=None, alias="type") + value: Union[Any, None] = None + + @classmethod + def from_field_info(cls: Type[Self], name: str, field_info: FieldInfo) -> Self: + return cls.model_validate({ + "name": name, + "type": cls.parse_type(field_info.annotation), + "value": field_info.default, + }) + + @staticmethod + def parse_type(python_type: Union[Type[Any], None]) -> str: + type_repr = repr(python_type) + + text_types = ("str",) + if any(text_type in type_repr for text_type in text_types): + return "text" + + number_types = ("float", "int") + if any(number_type in type_repr for number_type in number_types): + return "number" + + return "text" diff --git a/fastapi_hypermodel/siren/siren_hypermodel.py b/fastapi_hypermodel/siren/siren_hypermodel.py new file mode 100644 index 0000000..4412b6f --- /dev/null +++ b/fastapi_hypermodel/siren/siren_hypermodel.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from typing import ( + Any, + Dict, + List, + Mapping, + Sequence, + Union, + cast, +) + +from pydantic import ( + ConfigDict, + Field, + model_serializer, + model_validator, +) +from typing_extensions import Self + +from fastapi_hypermodel.base import AbstractHyperField, HyperModel + +from .siren_action import SirenActionFor, SirenActionType +from .siren_base import SirenBase +from .siren_link import SirenLinkFor, SirenLinkType + + +class SirenEntityType(SirenBase): + properties: Union[Mapping[str, Any], None] = None + entities: Union[Sequence[Union[SirenEmbeddedType, SirenLinkType]], None] = None + links: Union[Sequence[SirenLinkType], None] = None + actions: Union[Sequence[SirenActionType], None] = None + + +class SirenEmbeddedType(SirenEntityType): + rel: Sequence[str] = Field() + + +SIREN_RESERVED_FIELDS = { + "properties", + "entities", + "links", + "actions", +} + + +class SirenHyperModel(HyperModel): + properties: Dict[str, Any] = Field(default_factory=dict) + entities: Sequence[Union[SirenEmbeddedType, SirenLinkType]] = Field( + default_factory=list + ) + links: Sequence[SirenLinkFor] = Field(default_factory=list) + actions: Sequence[SirenActionFor] = Field(default_factory=list) + + # This config is needed to use the Self in Embedded + model_config = ConfigDict(arbitrary_types_allowed=True) + + @model_validator(mode="after") + def add_hypermodels_to_entities(self: Self) -> Self: + entities: List[Union[SirenEmbeddedType, SirenLinkType]] = [] + for name, field in self: + alias = self.model_fields[name].alias or name + + if alias in SIREN_RESERVED_FIELDS: + continue + + value: Sequence[Union[Any, Self]] = ( + field if isinstance(field, Sequence) else [field] + ) + + if not all( + isinstance(element, (SirenHyperModel, SirenLinkType)) + for element in value + ): + continue + + for field_ in value: + if isinstance(field_, SirenLinkType): + entities.append(field_) + continue + + child = self.as_embedded(field_, alias) + entities.append(child) + + delattr(self, name) + + self.entities = entities + + return self + + @model_validator(mode="after") + def add_properties(self: Self) -> Self: + properties = {} + for name, field in self: + alias = self.model_fields[name].alias or name + + if alias in SIREN_RESERVED_FIELDS: + continue + + value: Sequence[Any] = field if isinstance(field, Sequence) else [field] + + omit_types: Any = ( + AbstractHyperField, + SirenLinkFor, + SirenLinkType, + SirenActionFor, + SirenActionType, + SirenHyperModel, + ) + if any(isinstance(value_, omit_types) for value_ in value): + continue + + properties[alias] = value if isinstance(field, Sequence) else field + + delattr(self, name) + + if not self.properties: + self.properties = {} + + self.properties.update(properties) + + return self + + @model_validator(mode="after") + def add_links(self: Self) -> Self: + links_key = "links" + validated_links: List[SirenLinkFor] = [] + for name, value in self: + alias = self.model_fields[name].alias or name + + if alias != links_key or not value: + continue + + links = cast(Sequence[SirenLinkFor], value) + properties = self.properties or {} + validated_links = self._validate_factory(links, properties) + self.links = validated_links + + self.validate_has_self_link(validated_links) + + return self + + @staticmethod + def validate_has_self_link(links: Sequence[SirenLinkFor]) -> None: + if not links: + return + + if any(link.rel == ["self"] for link in links): + return + + error_message = "If links are present, a link with rel self must be present" + raise ValueError(error_message) + + @model_validator(mode="after") + def add_actions(self: Self) -> Self: + actions_key = "actions" + for name, value in self: + alias = self.model_fields[name].alias or name + + if alias != actions_key or not value: + continue + + properties = self.properties or {} + actions = cast(Sequence[SirenActionFor], value) + self.actions = self._validate_factory(actions, properties) + + return self + + @model_validator(mode="after") + def no_action_outside_of_actions(self: Self) -> Self: + for _, field in self: + if not isinstance(field, (SirenActionFor, SirenActionType)): + continue + + error_message = "All actions must be inside the actions property" + raise ValueError(error_message) + + return self + + @model_serializer + def serialize(self: Self) -> Mapping[str, Any]: + return {self.model_fields[k].alias or k: v for k, v in self if v} + + @staticmethod + def as_embedded(field: SirenHyperModel, rel: str) -> SirenEmbeddedType: + return SirenEmbeddedType(rel=[rel], **field.model_dump()) + + def parse_uri(self: Self, uri_template: str) -> str: + return self._parse_uri(self.properties, uri_template) diff --git a/fastapi_hypermodel/siren/siren_link.py b/fastapi_hypermodel/siren/siren_link.py new file mode 100644 index 0000000..1336e27 --- /dev/null +++ b/fastapi_hypermodel/siren/siren_link.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from typing import ( + Any, + Callable, + Mapping, + Sequence, + Type, + Union, +) + +from pydantic import ( + Field, + PrivateAttr, + field_validator, +) +from starlette.applications import Starlette +from starlette.routing import Route +from typing_extensions import Self + +from fastapi_hypermodel.base import ( + AbstractHyperField, + HasName, + UrlType, + get_route_from_app, + resolve_param_values, +) + +from .siren_base import SirenBase + + +class SirenLinkType(SirenBase): + rel: Sequence[str] = Field(default_factory=list) + href: UrlType = Field(default=UrlType()) + type_: Union[str, None] = Field(default=None, alias="type") + + @field_validator("rel", "href") + @classmethod + def mandatory(cls: Type[Self], value: Union[str, None]) -> str: + if not value: + error_message = "Field rel and href are mandatory" + raise ValueError(error_message) + return value + + +class SirenLinkFor(SirenLinkType, AbstractHyperField[SirenLinkType]): + # pylint: disable=too-many-instance-attributes + _endpoint: str = PrivateAttr() + _param_values: Mapping[str, str] = PrivateAttr() + _templated: bool = PrivateAttr() + _condition: Union[Callable[[Mapping[str, Any]], bool], None] = PrivateAttr() + + # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal + _title: Union[str, None] = PrivateAttr() + _type: Union[str, None] = PrivateAttr() + _rel: Sequence[str] = PrivateAttr() + _class: Union[Sequence[str], None] = PrivateAttr() + + def __init__( + self: Self, + endpoint: Union[HasName, str], + param_values: Union[Mapping[str, str], None] = None, + templated: bool = False, + condition: Union[Callable[[Mapping[str, Any]], bool], None] = None, + title: Union[str, None] = None, + type_: Union[str, None] = None, + rel: Union[Sequence[str], None] = None, + class_: Union[Sequence[str], None] = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._endpoint = ( + endpoint.__name__ if isinstance(endpoint, HasName) else endpoint + ) + self._param_values = param_values or {} + self._templated = templated + self._condition = condition + self._title = title + self._type = type_ + self._rel = rel or [] + self._class = class_ + + def _get_uri_path( + self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] + ) -> UrlType: + if self._templated and isinstance(route, Route): + return UrlType(route.path) + + params = resolve_param_values(self._param_values, values) + return UrlType(app.url_path_for(self._endpoint, **params)) + + def __call__( + self: Self, app: Union[Starlette, None], values: Mapping[str, Any] + ) -> Union[SirenLinkType, None]: + if app is None: + return None + + if self._condition and not self._condition(values): + return None + + route = get_route_from_app(app, self._endpoint) + + properties = values.get("properties", values) + uri_path = self._get_uri_path(app, properties, route) + + # Using model_validate to avoid conflicts with keyword class + return SirenLinkType.model_validate({ + "href": uri_path, + "rel": self._rel, + "title": self._title, + "type": self._type, + "class": self._class, + }) diff --git a/fastapi_hypermodel/siren/siren_response.py b/fastapi_hypermodel/siren/siren_response.py new file mode 100644 index 0000000..ea6cd9b --- /dev/null +++ b/fastapi_hypermodel/siren/siren_response.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import ( + Any, + Union, +) + +import jsonschema +from starlette.responses import JSONResponse +from typing_extensions import Self + +from .siren_action import SirenActionType +from .siren_link import SirenLinkType +from .siren_schema import schema + + +class SirenResponse(JSONResponse): + media_type = "application/siren+json" + + @staticmethod + def _validate(content: Any) -> None: + jsonschema.validate(instance=content, schema=schema) + + def render(self: Self, content: Any) -> bytes: + self._validate(content) + return super().render(content) + + +def get_siren_link(response: Any, link_name: str) -> Union[SirenLinkType, None]: + links = response.get("links", []) + link = next((link for link in links if link_name in link.get("rel")), None) + return SirenLinkType.model_validate(link) if link else None + + +def get_siren_action(response: Any, action_name: str) -> Union[SirenActionType, None]: + actions = response.get("actions", []) + action = next( + (action for action in actions if action_name in action.get("name")), None + ) + return SirenActionType.model_validate(action) if action else None diff --git a/fastapi_hypermodel/siren_schema.py b/fastapi_hypermodel/siren/siren_schema.py similarity index 100% rename from fastapi_hypermodel/siren_schema.py rename to fastapi_hypermodel/siren/siren_schema.py diff --git a/fastapi_hypermodel/url_for/__init__.py b/fastapi_hypermodel/url_for/__init__.py new file mode 100644 index 0000000..b04ba31 --- /dev/null +++ b/fastapi_hypermodel/url_for/__init__.py @@ -0,0 +1,3 @@ +from .url_for import UrlFor, UrlForType + +__all__ = ["UrlFor", "UrlForType"] diff --git a/fastapi_hypermodel/url_for.py b/fastapi_hypermodel/url_for/url_for.py similarity index 93% rename from fastapi_hypermodel/url_for.py rename to fastapi_hypermodel/url_for/url_for.py index b81bffc..9f5013f 100644 --- a/fastapi_hypermodel/url_for.py +++ b/fastapi_hypermodel/url_for/url_for.py @@ -18,12 +18,14 @@ from starlette.applications import Starlette from typing_extensions import Self -from fastapi_hypermodel.hypermodel import ( +from fastapi_hypermodel.base import ( + URL_TYPE_SCHEMA, AbstractHyperField, HasName, + UrlType, + get_route_from_app, + resolve_param_values, ) -from fastapi_hypermodel.url_type import URL_TYPE_SCHEMA, UrlType -from fastapi_hypermodel.utils import get_route_from_app, resolve_param_values class UrlForType(BaseModel): From 48bd60f3133e2737b7e2fe0c0df5009064f41458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 00:08:59 -0300 Subject: [PATCH 37/66] Remove LinkSet --- examples/hal/app.py | 28 ++++---- fastapi_hypermodel/linkset.py | 82 ---------------------- tests/test_hal.py | 29 ++++---- tests/test_linkset.py | 124 ---------------------------------- 4 files changed, 26 insertions(+), 237 deletions(-) delete mode 100644 fastapi_hypermodel/linkset.py delete mode 100644 tests/test_linkset.py diff --git a/examples/hal/app.py b/examples/hal/app.py index 3592d1b..044d773 100644 --- a/examples/hal/app.py +++ b/examples/hal/app.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, Sequence, cast +from typing import Any, Dict, Optional, Sequence, cast from fastapi import FastAPI, HTTPException from pydantic import Field @@ -10,8 +10,8 @@ from fastapi_hypermodel import ( HALFor, HalHyperModel, + HALLinkType, HALResponse, - LinkSet, ) @@ -19,11 +19,11 @@ class ItemSummary(HalHyperModel): name: str id_: str - links: LinkSet = Field( - default=LinkSet({ + links: Dict[str, HALLinkType] = Field( + default={ "self": HALFor("read_item", {"id_": ""}), "update": HALFor("update_item", {"id_": ""}), - }), + }, alias="_links", ) @@ -46,12 +46,12 @@ class ItemCreate(ItemUpdate): class ItemCollection(HalHyperModel): items: Sequence[Item] = Field(alias="sc:items") - links: LinkSet = Field( - default=LinkSet({ + links: Dict[str, HALLinkType] = Field( + default={ "self": HALFor("read_items"), "find": HALFor("read_item", templated=True), "update": HALFor("update_item", templated=True), - }), + }, alias="_links", ) @@ -63,8 +63,8 @@ class Person(HalHyperModel): items: Sequence[Item] = Field(alias="sc:items") - links: LinkSet = Field( - default=LinkSet({ + links: Dict[str, HALLinkType] = Field( + default={ "self": HALFor("read_person", {"id_": ""}), "update": HALFor("update_person", {"id_": ""}), "add_item": HALFor( @@ -73,7 +73,7 @@ class Person(HalHyperModel): description="Add an item to this person and the items list", condition=lambda values: not values["is_locked"], ), - }), + }, alias="_links", ) @@ -81,8 +81,8 @@ class Person(HalHyperModel): class PersonCollection(HalHyperModel): people: Sequence[Person] - links: LinkSet = Field( - default=LinkSet({ + links: Dict[str, HALLinkType] = Field( + default={ "self": HALFor("read_people"), "find": HALFor( "read_person", description="Get a particular person", templated=True @@ -92,7 +92,7 @@ class PersonCollection(HalHyperModel): description="Update a particular person", templated=True, ), - }), + }, alias="_links", ) diff --git a/fastapi_hypermodel/linkset.py b/fastapi_hypermodel/linkset.py deleted file mode 100644 index ffb4921..0000000 --- a/fastapi_hypermodel/linkset.py +++ /dev/null @@ -1,82 +0,0 @@ -from typing import ( - Any, - Dict, - List, - Mapping, - Optional, - Sequence, - Type, - Union, - cast, -) - -from pydantic import ( - BaseModel, - Field, - GetJsonSchemaHandler, - PrivateAttr, - model_serializer, -) -from pydantic.json_schema import JsonSchemaValue -from pydantic_core import CoreSchema -from starlette.applications import Starlette -from typing_extensions import Self - -from fastapi_hypermodel.hypermodel import AbstractHyperField - -LinkType = Union[AbstractHyperField[Any], Sequence[AbstractHyperField[Any]]] - - -class LinkSetType(BaseModel): - mapping: Mapping[str, LinkType] = Field(default_factory=dict) - - @model_serializer - def serialize(self: Self) -> Mapping[str, LinkType]: - return self if isinstance(self, Mapping) else self.mapping - - -class LinkSet(LinkSetType, AbstractHyperField[LinkSetType]): - _mapping: Mapping[str, LinkType] = PrivateAttr(default_factory=dict) - - def __init__( - self: Self, - mapping: Optional[Mapping[str, LinkType]] = None, - ) -> None: - super().__init__() - self._mapping = mapping or {} - - @classmethod - def __get_pydantic_json_schema__( - cls: Type[Self], __core_schema: CoreSchema, handler: GetJsonSchemaHandler - ) -> JsonSchemaValue: - json_schema = handler(__core_schema) - json_schema = handler.resolve_ref_schema(json_schema) - json_schema["type"] = "object" - - subclasses_schemas = AbstractHyperField.__schema_subclasses__(cls) - json_schema["additionalProperties"] = {"anyOf": subclasses_schemas} - - nested_properties_value = "properties" - if nested_properties_value in json_schema: - del json_schema[nested_properties_value] - - return json_schema - - def __call__( - self: Self, app: Optional[Starlette], values: Mapping[str, Any] - ) -> LinkSetType: - links: Dict[str, LinkType] = {} - - for key, hyperfields in self._mapping.items(): - hypermedia: Union[List[Any], Any] = [] - if isinstance(hyperfields, Sequence): - hypermedia = [hyperfield(app, values) for hyperfield in hyperfields] - else: - hypermedia = hyperfields(app, values) - - if not hypermedia: - continue - - links[key] = cast(LinkType, hypermedia) - - return LinkSetType(mapping=links) diff --git a/tests/test_hal.py b/tests/test_hal.py index 59c6a0e..8a4af7c 100644 --- a/tests/test_hal.py +++ b/tests/test_hal.py @@ -1,5 +1,5 @@ import uuid -from typing import Any, Generator, List, Mapping, Sequence +from typing import Any, Dict, Generator, List, Mapping, Sequence import pytest from fastapi import FastAPI @@ -11,8 +11,8 @@ HALFor, HALForType, HalHyperModel, + HALLinkType, HALResponse, - LinkSet, UrlType, ) @@ -20,10 +20,10 @@ class MockClass(HalHyperModel): id_: str - links: LinkSet = Field( - default=LinkSet({ + links: Dict[str, HALLinkType] = Field( + default={ "self": HALFor("mock_read_with_path_hal", {"id_": ""}), - }), + }, alias="_links", ) @@ -62,11 +62,11 @@ class MockClassWithEmbeddedListAliased(HalHyperModel): class MockClassWithCuries(HalHyperModel): id_: str - links: LinkSet = Field( - default=LinkSet({ + links: Dict[str, HALLinkType] = Field( + default={ "self": HALFor("mock_read_with_path_hal", {"id_": ""}), "sc:item": HALFor("mock_read_with_path_hal", {"id_": ""}), - }), + }, alias="_links", ) @@ -74,11 +74,11 @@ class MockClassWithCuries(HalHyperModel): class MockClassWithMissingCuries(HalHyperModel): id_: str - links: LinkSet = Field( - default=LinkSet({ + links: Dict[str, HALLinkType] = Field( + default={ "self": HALFor("mock_read_with_path_hal", {"id_": ""}), "missing:item": HALFor("mock_read_with_path_hal", {"id_": ""}), - }), + }, alias="_links", ) @@ -500,12 +500,7 @@ def test_build_hypermedia_with_href(app: FastAPI) -> None: def test_openapi_schema(hal_for_schema: Mapping[str, Any]) -> None: mock = MockClass(id_="test") schema = mock.model_json_schema() - link_set_definition = schema["$defs"]["LinkSet"]["additionalProperties"]["anyOf"] - hal_for_definition = next( - definition - for definition in link_set_definition - if definition.get("title") == "HALFor" - ) + hal_for_definition = schema["$defs"]["HALFor"] assert all(hal_for_definition.get(k) == v for k, v in hal_for_schema.items()) diff --git a/tests/test_linkset.py b/tests/test_linkset.py deleted file mode 100644 index 691fa32..0000000 --- a/tests/test_linkset.py +++ /dev/null @@ -1,124 +0,0 @@ -from typing import Any, Optional - -from fastapi import FastAPI -from pydantic import BaseModel, PrivateAttr -from typing_extensions import Self - -from fastapi_hypermodel import ( - AbstractHyperField, - HyperModel, - LinkSet, -) - - -class MockHypermediaType(BaseModel): - href: Optional[str] = None - - def __bool__(self: Self) -> bool: - return bool(self.href) - - -class MockHypermedia(MockHypermediaType, AbstractHyperField[MockHypermediaType]): - _href: Optional[str] = PrivateAttr() - - def __init__(self: Self, href: Optional[str] = None) -> None: - super().__init__() - self._href = href - - def __call__(self: Self, *_: Any) -> MockHypermediaType: - return MockHypermediaType(href=self._href) - - -class MockHypermediaEmpty(AbstractHyperField[MockHypermediaType]): - def __call__(self: Self, *_: Any) -> MockHypermediaType: - return MockHypermediaType() - - -class MockClassLinkSet(HyperModel): - test_field: LinkSet = LinkSet({ - "self": MockHypermedia("test"), - }) - - -class MockClassLinkSetEmpty(HyperModel): - test_field: LinkSet = LinkSet() - - -class MockClassLinkSetWithEmptyHypermedia(HyperModel): - test_field: LinkSet = LinkSet({ - "self": MockHypermedia("test"), - "other": MockHypermediaEmpty(), - }) - - -class MockClassLinkSetWithMultipleHypermedia(HyperModel): - test_field: LinkSet = LinkSet({ - "self": MockHypermedia("test"), - "other": [MockHypermedia("test"), MockHypermedia("test2")], - }) - - -def test_linkset_in_hypermodel() -> None: - linkset = MockClassLinkSet() - hypermedia = linkset.model_dump() - test_field = hypermedia.get("test_field") - assert test_field - - expected = {"self": {"href": "test"}} - assert test_field == expected - - -def test_linkset_in_hypermodel_with_link_list() -> None: - linkset = MockClassLinkSetWithMultipleHypermedia() - hypermedia = linkset.model_dump() - test_field = hypermedia.get("test_field") - assert test_field - - expected = { - "self": {"href": "test"}, - "other": [{"href": "test"}, {"href": "test2"}], - } - assert test_field == expected - - -def test_linkset_in_hypermodel_empty() -> None: - linkset = MockClassLinkSetEmpty() - hypermedia = linkset.model_dump() - test_field = hypermedia.get("test_field") - expected = {} - assert test_field == expected - - -def test_linkset_in_hypermodel_with_empty_hypermedia() -> None: - linkset = MockClassLinkSetWithEmptyHypermedia() - hypermedia = linkset.model_dump() - test_field = hypermedia.get("test_field") - assert test_field - - expected = {"self": {"href": "test"}} - assert test_field == expected - - -def test_linkset_schema() -> None: - linkset = MockClassLinkSet() - schema = linkset.model_json_schema()["$defs"]["LinkSet"] - - schema_type = schema["type"] - assert schema_type == "object" - - assert "properties" not in schema - assert "additionalProperties" in schema - - -def test_linkset_empty(app: FastAPI) -> None: - linkset = LinkSet() - hypermedia = linkset(app, {}) - assert hypermedia - assert hypermedia.mapping == {} - - -def test_linkset_empty_no_app() -> None: - linkset = LinkSet() - hypermedia = linkset(None, {}) - assert hypermedia - assert hypermedia.mapping == {} From dd9e37d3e5aa5a0e3b169b15c2f5885dc2c262d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 00:29:42 -0300 Subject: [PATCH 38/66] Populate values by name and not alias --- fastapi_hypermodel/hal/hal_hypermodel.py | 8 ++++++-- fastapi_hypermodel/siren/siren_action.py | 26 ++++++++++++++---------- fastapi_hypermodel/siren/siren_base.py | 3 +++ fastapi_hypermodel/siren/siren_field.py | 13 +++++++----- fastapi_hypermodel/siren/siren_link.py | 17 +++++++++------- 5 files changed, 42 insertions(+), 25 deletions(-) diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index cd39da0..1ba3389 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -31,11 +31,15 @@ class HALForType(BaseModel): templated: Optional[bool] = None title: Optional[str] = None name: Optional[str] = None - type: Optional[str] = None + type_: Union[str, None] = Field(default=None, alias="type") hreflang: Optional[str] = None profile: Optional[str] = None deprecation: Optional[str] = None + model_config = ConfigDict( + populate_by_name=True, + ) + def __bool__(self: Self) -> bool: return bool(self.href) @@ -110,7 +114,7 @@ def __call__( templated=self._templated, title=self._title, name=self._name, - type=self._type, + type_=self._type, # type: ignore hreflang=self._hreflang, profile=self._profile, deprecation=self._deprecation, diff --git a/fastapi_hypermodel/siren/siren_action.py b/fastapi_hypermodel/siren/siren_action.py index 5948ee7..0a4e5a6 100644 --- a/fastapi_hypermodel/siren/siren_action.py +++ b/fastapi_hypermodel/siren/siren_action.py @@ -15,6 +15,7 @@ from fastapi.routing import APIRoute from pydantic import ( + ConfigDict, Field, PrivateAttr, field_validator, @@ -44,6 +45,10 @@ class SirenActionType(SirenBase): fields: Union[Sequence[SirenFieldType], None] = Field(default=None) templated: bool = Field(default=False) + model_config = ConfigDict( + populate_by_name=True, + ) + @field_validator("name", "href") @classmethod def mandatory(cls: Type[Self], value: Union[str, None]) -> str: @@ -158,14 +163,13 @@ def __call__( if not self._type and self._fields: self._type = "application/x-www-form-urlencoded" - # Using model_validate to avoid conflicts with class and type - return SirenActionType.model_validate({ - "href": uri_path, - "name": self._name, - "fields": self._fields, - "method": self._method, - "title": self._title, - "type": self._type, - "class": self._class, - "templated": self._templated, - }) + return SirenActionType( + href=uri_path, + name=self._name, + fields=self._fields, + method=self._method, + title=self._title, + type_=self._type, # type: ignore + class_=self._class, # type: ignore + templated=self._templated, + ) diff --git a/fastapi_hypermodel/siren/siren_base.py b/fastapi_hypermodel/siren/siren_base.py index fd459f7..6c9ed6f 100644 --- a/fastapi_hypermodel/siren/siren_base.py +++ b/fastapi_hypermodel/siren/siren_base.py @@ -9,6 +9,7 @@ from pydantic import ( BaseModel, + ConfigDict, Field, model_serializer, ) @@ -19,6 +20,8 @@ class SirenBase(BaseModel): class_: Union[Sequence[str], None] = Field(default=None, alias="class") title: Union[str, None] = Field(default=None) + model_config = ConfigDict(populate_by_name=True) + @model_serializer def serialize(self: Self) -> Mapping[str, Any]: return {self.model_fields[k].alias or k: v for k, v in self if v} diff --git a/fastapi_hypermodel/siren/siren_field.py b/fastapi_hypermodel/siren/siren_field.py index 37d9bb6..2db4f6e 100644 --- a/fastapi_hypermodel/siren/siren_field.py +++ b/fastapi_hypermodel/siren/siren_field.py @@ -7,6 +7,7 @@ ) from pydantic import ( + ConfigDict, Field, ) from pydantic.fields import FieldInfo @@ -20,13 +21,15 @@ class SirenFieldType(SirenBase): type_: Union[str, None] = Field(default=None, alias="type") value: Union[Any, None] = None + model_config = ConfigDict(populate_by_name=True) + @classmethod def from_field_info(cls: Type[Self], name: str, field_info: FieldInfo) -> Self: - return cls.model_validate({ - "name": name, - "type": cls.parse_type(field_info.annotation), - "value": field_info.default, - }) + return cls( + name=name, + type_=cls.parse_type(field_info.annotation), # type: ignore + value=field_info.default, + ) @staticmethod def parse_type(python_type: Union[Type[Any], None]) -> str: diff --git a/fastapi_hypermodel/siren/siren_link.py b/fastapi_hypermodel/siren/siren_link.py index 1336e27..b7df5cc 100644 --- a/fastapi_hypermodel/siren/siren_link.py +++ b/fastapi_hypermodel/siren/siren_link.py @@ -10,6 +10,7 @@ ) from pydantic import ( + ConfigDict, Field, PrivateAttr, field_validator, @@ -34,6 +35,8 @@ class SirenLinkType(SirenBase): href: UrlType = Field(default=UrlType()) type_: Union[str, None] = Field(default=None, alias="type") + model_config = ConfigDict(populate_by_name=True) + @field_validator("rel", "href") @classmethod def mandatory(cls: Type[Self], value: Union[str, None]) -> str: @@ -104,10 +107,10 @@ def __call__( uri_path = self._get_uri_path(app, properties, route) # Using model_validate to avoid conflicts with keyword class - return SirenLinkType.model_validate({ - "href": uri_path, - "rel": self._rel, - "title": self._title, - "type": self._type, - "class": self._class, - }) + return SirenLinkType( + href=uri_path, + rel=self._rel, + title=self._title, + type_=self._type, # type: ignore + class_=self._class, # type: ignore + ) From f7aaed66803edbc3daf0f10d2603df9ed96b7bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 00:49:13 -0300 Subject: [PATCH 39/66] Simplify HAL Links in user code --- examples/hal/app.py | 76 ++++++++++-------------- fastapi_hypermodel/__init__.py | 4 +- fastapi_hypermodel/hal/__init__.py | 4 +- fastapi_hypermodel/hal/hal_hypermodel.py | 10 +++- tests/test_hal.py | 35 ++++------- 5 files changed, 57 insertions(+), 72 deletions(-) diff --git a/examples/hal/app.py b/examples/hal/app.py index 044d773..4cb8b29 100644 --- a/examples/hal/app.py +++ b/examples/hal/app.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional, Sequence, cast +from typing import Any, Optional, Sequence, cast from fastapi import FastAPI, HTTPException from pydantic import Field @@ -10,22 +10,19 @@ from fastapi_hypermodel import ( HALFor, HalHyperModel, - HALLinkType, HALResponse, ) +from fastapi_hypermodel.hal.hal_hypermodel import HALLinks class ItemSummary(HalHyperModel): name: str id_: str - links: Dict[str, HALLinkType] = Field( - default={ - "self": HALFor("read_item", {"id_": ""}), - "update": HALFor("update_item", {"id_": ""}), - }, - alias="_links", - ) + links: HALLinks = { + "self": HALFor("read_item", {"id_": ""}), + "update": HALFor("update_item", {"id_": ""}), + } class Item(ItemSummary): @@ -46,14 +43,11 @@ class ItemCreate(ItemUpdate): class ItemCollection(HalHyperModel): items: Sequence[Item] = Field(alias="sc:items") - links: Dict[str, HALLinkType] = Field( - default={ - "self": HALFor("read_items"), - "find": HALFor("read_item", templated=True), - "update": HALFor("update_item", templated=True), - }, - alias="_links", - ) + links: HALLinks = { + "self": HALFor("read_items"), + "find": HALFor("read_item", templated=True), + "update": HALFor("update_item", templated=True), + } class Person(HalHyperModel): @@ -63,38 +57,32 @@ class Person(HalHyperModel): items: Sequence[Item] = Field(alias="sc:items") - links: Dict[str, HALLinkType] = Field( - default={ - "self": HALFor("read_person", {"id_": ""}), - "update": HALFor("update_person", {"id_": ""}), - "add_item": HALFor( - "put_person_items", - {"id_": ""}, - description="Add an item to this person and the items list", - condition=lambda values: not values["is_locked"], - ), - }, - alias="_links", - ) + links: HALLinks = { + "self": HALFor("read_person", {"id_": ""}), + "update": HALFor("update_person", {"id_": ""}), + "add_item": HALFor( + "put_person_items", + {"id_": ""}, + description="Add an item to this person and the items list", + condition=lambda values: not values["is_locked"], + ), + } class PersonCollection(HalHyperModel): people: Sequence[Person] - links: Dict[str, HALLinkType] = Field( - default={ - "self": HALFor("read_people"), - "find": HALFor( - "read_person", description="Get a particular person", templated=True - ), - "update": HALFor( - "update_person", - description="Update a particular person", - templated=True, - ), - }, - alias="_links", - ) + links: HALLinks = { + "self": HALFor("read_people"), + "find": HALFor( + "read_person", description="Get a particular person", templated=True + ), + "update": HALFor( + "update_person", + description="Update a particular person", + templated=True, + ), + } class PersonUpdate(BaseModel): diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index 97ff735..3a50a8c 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -10,7 +10,7 @@ get_route_from_app, resolve_param_values, ) -from .hal import HALFor, HALForType, HalHyperModel, HALLinkType, HALResponse +from .hal import HALFor, HALForType, HalHyperModel, HALLinks, HALResponse from .siren import ( SirenActionFor, SirenActionType, @@ -30,7 +30,7 @@ "AbstractHyperField", "HALFor", "HALForType", - "HALLinkType", + "HALLinks", "HALResponse", "HalHyperModel", "HasName", diff --git a/fastapi_hypermodel/hal/__init__.py b/fastapi_hypermodel/hal/__init__.py index ba0fa11..c59de2e 100644 --- a/fastapi_hypermodel/hal/__init__.py +++ b/fastapi_hypermodel/hal/__init__.py @@ -1,10 +1,10 @@ -from .hal_hypermodel import HALFor, HALForType, HalHyperModel, HALLinkType +from .hal_hypermodel import HALFor, HALForType, HalHyperModel, HALLinks from .hal_response import HALResponse __all__ = [ "HALFor", "HALForType", - "HALLinkType", + "HALLinks", "HALResponse", "HalHyperModel", ] diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index 1ba3389..f7dbe38 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -11,10 +11,11 @@ cast, ) +from frozendict import frozendict from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator from starlette.applications import Starlette from starlette.routing import Route -from typing_extensions import Self +from typing_extensions import Annotated, Self from fastapi_hypermodel.base import ( AbstractHyperField, @@ -123,10 +124,12 @@ def __call__( HALLinkType = Union[HALFor, Sequence[HALFor]] +HALLinks = Annotated[Union[Dict[str, HALLinkType], None], Field(alias="_links")] + class HalHyperModel(HyperModel): curies_: ClassVar[Optional[Sequence[HALForType]]] = None - links: Dict[str, HALLinkType] = Field(default_factory=dict, alias="_links") + links: HALLinks = None embedded: Mapping[str, Union[Self, Sequence[Self]]] = Field( default_factory=dict, alias="_embedded" ) @@ -145,6 +148,9 @@ def curies(cls: Type[Self]) -> Sequence[HALForType]: @model_validator(mode="after") def add_links(self: Self) -> Self: links_key = "_links" + if not self.links: + self.links = {} + for name, value in self: alias = self.model_fields[name].alias or name diff --git a/tests/test_hal.py b/tests/test_hal.py index 8a4af7c..14faea6 100644 --- a/tests/test_hal.py +++ b/tests/test_hal.py @@ -1,5 +1,5 @@ import uuid -from typing import Any, Dict, Generator, List, Mapping, Sequence +from typing import Any, Generator, List, Mapping, Sequence import pytest from fastapi import FastAPI @@ -11,7 +11,7 @@ HALFor, HALForType, HalHyperModel, - HALLinkType, + HALLinks, HALResponse, UrlType, ) @@ -20,12 +20,9 @@ class MockClass(HalHyperModel): id_: str - links: Dict[str, HALLinkType] = Field( - default={ - "self": HALFor("mock_read_with_path_hal", {"id_": ""}), - }, - alias="_links", - ) + links: HALLinks = { + "self": HALFor("mock_read_with_path_hal", {"id_": ""}), + } class MockClassWithEmbedded(HalHyperModel): @@ -62,25 +59,19 @@ class MockClassWithEmbeddedListAliased(HalHyperModel): class MockClassWithCuries(HalHyperModel): id_: str - links: Dict[str, HALLinkType] = Field( - default={ - "self": HALFor("mock_read_with_path_hal", {"id_": ""}), - "sc:item": HALFor("mock_read_with_path_hal", {"id_": ""}), - }, - alias="_links", - ) + links: HALLinks = { + "self": HALFor("mock_read_with_path_hal", {"id_": ""}), + "sc:item": HALFor("mock_read_with_path_hal", {"id_": ""}), + } class MockClassWithMissingCuries(HalHyperModel): id_: str - links: Dict[str, HALLinkType] = Field( - default={ - "self": HALFor("mock_read_with_path_hal", {"id_": ""}), - "missing:item": HALFor("mock_read_with_path_hal", {"id_": ""}), - }, - alias="_links", - ) + links: HALLinks = { + "self": HALFor("mock_read_with_path_hal", {"id_": ""}), + "missing:item": HALFor("mock_read_with_path_hal", {"id_": ""}), + } @pytest.fixture() From 8f9a195eda287ebd2f2cbc21a190c1e3884a3115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 02:24:55 -0300 Subject: [PATCH 40/66] Use Frozen Types for HAL Links --- examples/hal/app.py | 17 ++++---- fastapi_hypermodel/__init__.py | 3 +- fastapi_hypermodel/hal/__init__.py | 3 +- fastapi_hypermodel/hal/hal_hypermodel.py | 54 +++++++++++++++++++++--- poetry.lock | 47 ++++++++++++++++++++- pyproject.toml | 1 + tests/test_hal.py | 13 +++--- 7 files changed, 115 insertions(+), 23 deletions(-) diff --git a/examples/hal/app.py b/examples/hal/app.py index 4cb8b29..876bf02 100644 --- a/examples/hal/app.py +++ b/examples/hal/app.py @@ -8,6 +8,7 @@ from examples.hal.data import Person as PersonData from examples.hal.data import curies, items, people from fastapi_hypermodel import ( + FrozenDict, HALFor, HalHyperModel, HALResponse, @@ -19,10 +20,10 @@ class ItemSummary(HalHyperModel): name: str id_: str - links: HALLinks = { + links: HALLinks = FrozenDict({ "self": HALFor("read_item", {"id_": ""}), "update": HALFor("update_item", {"id_": ""}), - } + }) class Item(ItemSummary): @@ -43,11 +44,11 @@ class ItemCreate(ItemUpdate): class ItemCollection(HalHyperModel): items: Sequence[Item] = Field(alias="sc:items") - links: HALLinks = { + links: HALLinks = FrozenDict({ "self": HALFor("read_items"), "find": HALFor("read_item", templated=True), "update": HALFor("update_item", templated=True), - } + }) class Person(HalHyperModel): @@ -57,7 +58,7 @@ class Person(HalHyperModel): items: Sequence[Item] = Field(alias="sc:items") - links: HALLinks = { + links: HALLinks = FrozenDict({ "self": HALFor("read_person", {"id_": ""}), "update": HALFor("update_person", {"id_": ""}), "add_item": HALFor( @@ -66,13 +67,13 @@ class Person(HalHyperModel): description="Add an item to this person and the items list", condition=lambda values: not values["is_locked"], ), - } + }) class PersonCollection(HalHyperModel): people: Sequence[Person] - links: HALLinks = { + links: HALLinks = FrozenDict({ "self": HALFor("read_people"), "find": HALFor( "read_person", description="Get a particular person", templated=True @@ -82,7 +83,7 @@ class PersonCollection(HalHyperModel): description="Update a particular person", templated=True, ), - } + }) class PersonUpdate(BaseModel): diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index 3a50a8c..cc61662 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -10,7 +10,7 @@ get_route_from_app, resolve_param_values, ) -from .hal import HALFor, HALForType, HalHyperModel, HALLinks, HALResponse +from .hal import FrozenDict, HALFor, HALForType, HalHyperModel, HALLinks, HALResponse from .siren import ( SirenActionFor, SirenActionType, @@ -28,6 +28,7 @@ __all__ = [ "URL_TYPE_SCHEMA", "AbstractHyperField", + "FrozenDict", "HALFor", "HALForType", "HALLinks", diff --git a/fastapi_hypermodel/hal/__init__.py b/fastapi_hypermodel/hal/__init__.py index c59de2e..d9aa14a 100644 --- a/fastapi_hypermodel/hal/__init__.py +++ b/fastapi_hypermodel/hal/__init__.py @@ -1,7 +1,8 @@ -from .hal_hypermodel import HALFor, HALForType, HalHyperModel, HALLinks +from .hal_hypermodel import FrozenDict, HALFor, HALForType, HalHyperModel, HALLinks from .hal_response import HALResponse __all__ = [ + "FrozenDict", "HALFor", "HALForType", "HALLinks", diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index f7dbe38..e2a684d 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -11,8 +11,17 @@ cast, ) +import pydantic_core from frozendict import frozendict -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + GetCoreSchemaHandler, + PrivateAttr, + field_serializer, + model_validator, +) from starlette.applications import Starlette from starlette.routing import Route from typing_extensions import Annotated, Self @@ -124,7 +133,33 @@ def __call__( HALLinkType = Union[HALFor, Sequence[HALFor]] -HALLinks = Annotated[Union[Dict[str, HALLinkType], None], Field(alias="_links")] + +class FrozenDict(frozendict[str, HALLinkType]): + @classmethod + def __get_pydantic_core_schema__( + cls: Type[Self], + __source: Type[BaseModel], + __handler: GetCoreSchemaHandler, + ) -> pydantic_core.CoreSchema: + hal_for_schema = HALFor.__get_pydantic_core_schema__(__source, __handler) + hal_for_type_schema = HALForType.__get_pydantic_core_schema__( + __source, __handler + ) + hal_link_schema = pydantic_core.core_schema.union_schema([ + hal_for_schema, + hal_for_type_schema, + ]) + link_schema = pydantic_core.core_schema.union_schema([ + hal_link_schema, + pydantic_core.core_schema.list_schema(hal_link_schema), + ]) + return pydantic_core.core_schema.dict_schema( + keys_schema=pydantic_core.core_schema.str_schema(), + values_schema=link_schema, + ) + + +HALLinks = Annotated[Union[FrozenDict, None], Field(alias="_links")] class HalHyperModel(HyperModel): @@ -148,9 +183,8 @@ def curies(cls: Type[Self]) -> Sequence[HALForType]: @model_validator(mode="after") def add_links(self: Self) -> Self: links_key = "_links" - if not self.links: - self.links = {} + validated_links: Dict[str, HALLinkType] = {} for name, value in self: alias = self.model_fields[name].alias or name @@ -168,9 +202,11 @@ def add_links(self: Self) -> Self: continue first_link, *_ = valid_links - self.links[link_name] = valid_links if is_sequence else first_link + validated_links[link_name] = valid_links if is_sequence else first_link - self.links["curies"] = HalHyperModel.curies() # type: ignore + validated_links["curies"] = HalHyperModel.curies() # type: ignore + + self.links = frozendict(validated_links) return self @@ -196,6 +232,12 @@ def add_hypermodels_to_embedded(self: Self) -> Self: return self + @field_serializer("links") + def serialize_links(self: Self, links: HALLinks) -> Dict[str, HALLinkType]: + if not links: + return {} + return dict(links.items()) + EmbeddedRawType = Union[Mapping[str, Union[Sequence[Any], Any]], Any] LinksRawType = Union[Mapping[str, Union[Any, Sequence[Any]]], Any] diff --git a/poetry.lock b/poetry.lock index eb1c7ae..30d1d86 100644 --- a/poetry.lock +++ b/poetry.lock @@ -408,6 +408,51 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1 testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] typing = ["typing-extensions (>=4.8)"] +[[package]] +name = "frozendict" +version = "2.4.0" +description = "A simple immutable dictionary" +optional = false +python-versions = ">=3.6" +files = [ + {file = "frozendict-2.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:475c65202a6f5421df8cacb8a2f29c5087134a0542b0540ae95fbf4db7af2ff9"}, + {file = "frozendict-2.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2607e82efdd2c277224a58bda3994d4cd48e49eff7fa31e404cf3066e8dbfeae"}, + {file = "frozendict-2.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fd4583194baabe100c135883017da76259a315d34e303eddf198541b7e02e44"}, + {file = "frozendict-2.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efca7281184b54f7abab6980cf25837b709f72ced62791f62dabcd7b184d958a"}, + {file = "frozendict-2.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fc4cba1ced988ce9020dfcaae6fe3f5521eebc00c5772b511aaf691b0be91e6"}, + {file = "frozendict-2.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8fab616e7c0fea2ac928f107c740bd9ba516fc083adfcd1c391d6bfc9164403d"}, + {file = "frozendict-2.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:09ba8ee37d260adde311b8eb4cd12bf27f64071242f736757ae6a11d331eb860"}, + {file = "frozendict-2.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:0615ed71570eec3cc96df063930ea6e563211efeeac86e3f3cc8bdfc9c9bfab7"}, + {file = "frozendict-2.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc754117a7d60ba8e55b3c39abd67f37fbc05dd63cdcb03d1717a382fe0a3421"}, + {file = "frozendict-2.4.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2804ea4bd2179bb33b99483cc8d69246630cc00632b9affe2914e8666f1cc7e5"}, + {file = "frozendict-2.4.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd4700c3f0aebdc8f4375c35590135794b1dbf2aca132f4756b584fa9910af2d"}, + {file = "frozendict-2.4.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:da4406d95c340e0b1cc43a3858fac729f52689325bcf61a9182eb94aff7451dc"}, + {file = "frozendict-2.4.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:1875e7b70a5724bf964354da8fd542240d2cead0d80053ac96bf4494ce3517fa"}, + {file = "frozendict-2.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a60f353496637ca21396289a7d969af1eb4ec4d11a7c37a0e7f25fc1761a0c97"}, + {file = "frozendict-2.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b666f9c6c8a9e794d2713a944b10a65480ff459579d75b5f686c75031c2c2dfc"}, + {file = "frozendict-2.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d81fb396ea81fcba3b3dde4a4b51adcb74ff31632014fbfd030f8acd5a7292"}, + {file = "frozendict-2.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4925c8e82d2bd23d45996cd0827668a52b9c51103897c98ce409a763d0c00c61"}, + {file = "frozendict-2.4.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aa86325da6a6071284b4ed3d9d2cd9db068560aebad503b658d6a889a0575683"}, + {file = "frozendict-2.4.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5bb5b62d4e2bce12e91800496d94de41bec8f16e4d8a7b16e8f263676ae2031a"}, + {file = "frozendict-2.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3909df909516cfd7bcefd9a3003948970a12a50c5648d8bbddafcef171f2117f"}, + {file = "frozendict-2.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:204f2c5c10fc018d1ba8ccc67758aa83fe769c782547bd26dc250317a7ccba71"}, + {file = "frozendict-2.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d8d1d269874c94b1ed2b6667e5e43dcf4541838019b1caa4c48f848ac73634df"}, + {file = "frozendict-2.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:809f1cffb602cf06e5186c69c0e3b74bec7a3684593145331f9aa2a65b5ba3b7"}, + {file = "frozendict-2.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b017cba5f73869b04c2977139ad08e57a7480de1e384c34193939698119baa1d"}, + {file = "frozendict-2.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0b75e5e231621dedaef88334997e79fbd137dd89895543d3862fe0220fc3572c"}, + {file = "frozendict-2.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:df3819a5d48ab3aae1548e62093d0111ad7c3b62ff9392421b7bbf149c08b629"}, + {file = "frozendict-2.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:42a9b33ccf9d417b22146e59803c53d5c39d7d9151d2df8df59c235f6a1a5ed7"}, + {file = "frozendict-2.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3f51bfa64e0c4a6608e3f2878bab1211a6b3b197de6fa57151bbe73f1184457"}, + {file = "frozendict-2.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a1d232f092dc686e6ef23d436bde30f82c018f31cef1b89b31caef03814b1617"}, + {file = "frozendict-2.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e530658134e88607ff8c2c8934a07b2bb5e9fffab5045f127746f6542c6c77e"}, + {file = "frozendict-2.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23a52bbea30c9e35b89291273944393770fb031e522a172e3aff19b62cc50047"}, + {file = "frozendict-2.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f91acaff475d0ef0d3436b805c9b91fc627a6a8a281771a24f7ab7f458a0b34f"}, + {file = "frozendict-2.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:08d9c7c1aa92b94538b3a79c43999f999012e174588435f197794d5e5a80e0f5"}, + {file = "frozendict-2.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:05c5a77957ecba4286c7ab33861a8f4f2badc7ea86fc82b834fb360d3aa4c108"}, + {file = "frozendict-2.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:c8af8a6a39e0050d3f3193cda56c42b43534a9b3995c44241bb9527e3c3fd451"}, + {file = "frozendict-2.4.0.tar.gz", hash = "sha256:c26758198e403337933a92b01f417a8240c954f553e1d4b5e0f8e39d9c8e3f0a"}, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -1893,4 +1938,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.8,<4.0" -content-hash = "725092e0e0479e8d2a545aa3af450280f294317f4c92a5b5dfe4a8f0d0893f4f" +content-hash = "f11d30b2e9ea7ce0b9a7c02baf3fbe81ff9c8b347096079e3ad1e7ae3146816a" diff --git a/pyproject.toml b/pyproject.toml index ed11b2f..66366b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ typing_extensions = ">=4.0.0" python = ">=3.8,<4.0" jsonref = ">=1.1.0,<2.0.0" jsonschema = ">=4.0.0,<5.0.0" +frozendict = "^2.4.0" [tool.poetry.group.dev.dependencies] bandit = "^1.7.0" diff --git a/tests/test_hal.py b/tests/test_hal.py index 14faea6..e755d5d 100644 --- a/tests/test_hal.py +++ b/tests/test_hal.py @@ -8,6 +8,7 @@ from pytest_lazy_fixtures import lf from fastapi_hypermodel import ( + FrozenDict, HALFor, HALForType, HalHyperModel, @@ -20,9 +21,9 @@ class MockClass(HalHyperModel): id_: str - links: HALLinks = { + links: HALLinks = FrozenDict({ "self": HALFor("mock_read_with_path_hal", {"id_": ""}), - } + }) class MockClassWithEmbedded(HalHyperModel): @@ -59,19 +60,19 @@ class MockClassWithEmbeddedListAliased(HalHyperModel): class MockClassWithCuries(HalHyperModel): id_: str - links: HALLinks = { + links: HALLinks = FrozenDict({ "self": HALFor("mock_read_with_path_hal", {"id_": ""}), "sc:item": HALFor("mock_read_with_path_hal", {"id_": ""}), - } + }) class MockClassWithMissingCuries(HalHyperModel): id_: str - links: HALLinks = { + links: HALLinks = FrozenDict({ "self": HALFor("mock_read_with_path_hal", {"id_": ""}), "missing:item": HALFor("mock_read_with_path_hal", {"id_": ""}), - } + }) @pytest.fixture() From 4791fe5627632147029e32b046fa9cd7434d17c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 08:54:49 -0300 Subject: [PATCH 41/66] Move HAL utility to the HAL module --- fastapi_hypermodel/__init__.py | 11 ++++-- fastapi_hypermodel/base/__init__.py | 2 -- fastapi_hypermodel/base/utils.py | 4 --- fastapi_hypermodel/hal/__init__.py | 3 +- fastapi_hypermodel/hal/hal_response.py | 6 ++++ tests/integration/hal/test_hal_items.py | 28 ++++++++------- tests/integration/hal/test_hal_people.py | 44 +++++++++++++----------- tests/test_utility_functions.py | 6 ++-- 8 files changed, 60 insertions(+), 44 deletions(-) diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index cc61662..3c1efbc 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -6,11 +6,18 @@ InvalidAttribute, UrlType, extract_value_by_name, - get_hal_link_href, get_route_from_app, resolve_param_values, ) -from .hal import FrozenDict, HALFor, HALForType, HalHyperModel, HALLinks, HALResponse +from .hal import ( + FrozenDict, + HALFor, + HALForType, + HalHyperModel, + HALLinks, + HALResponse, + get_hal_link_href, +) from .siren import ( SirenActionFor, SirenActionType, diff --git a/fastapi_hypermodel/base/__init__.py b/fastapi_hypermodel/base/__init__.py index 0ff8f8e..115777e 100644 --- a/fastapi_hypermodel/base/__init__.py +++ b/fastapi_hypermodel/base/__init__.py @@ -3,7 +3,6 @@ from .utils import ( InvalidAttribute, extract_value_by_name, - get_hal_link_href, get_route_from_app, resolve_param_values, ) @@ -16,7 +15,6 @@ "InvalidAttribute", "UrlType", "extract_value_by_name", - "get_hal_link_href", "get_route_from_app", "resolve_param_values", ] diff --git a/fastapi_hypermodel/base/utils.py b/fastapi_hypermodel/base/utils.py index d15ba7f..baabb31 100644 --- a/fastapi_hypermodel/base/utils.py +++ b/fastapi_hypermodel/base/utils.py @@ -108,10 +108,6 @@ def extract_value_by_name( return _clean_attribute_value(attribute_value) -def get_hal_link_href(response: Any, link_name: str) -> Union[str, Any]: - return response.get("_links", {}).get(link_name, {}).get("href", "") - - def get_route_from_app(app: Starlette, endpoint_function: str) -> Route: for route in app.routes: if isinstance(route, Route) and route.name == endpoint_function: diff --git a/fastapi_hypermodel/hal/__init__.py b/fastapi_hypermodel/hal/__init__.py index d9aa14a..c445a42 100644 --- a/fastapi_hypermodel/hal/__init__.py +++ b/fastapi_hypermodel/hal/__init__.py @@ -1,5 +1,5 @@ from .hal_hypermodel import FrozenDict, HALFor, HALForType, HalHyperModel, HALLinks -from .hal_response import HALResponse +from .hal_response import HALResponse, get_hal_link_href __all__ = [ "FrozenDict", @@ -8,4 +8,5 @@ "HALLinks", "HALResponse", "HalHyperModel", + "get_hal_link_href", ] diff --git a/fastapi_hypermodel/hal/hal_response.py b/fastapi_hypermodel/hal/hal_response.py index 642bdc0..eeecbab 100644 --- a/fastapi_hypermodel/hal/hal_response.py +++ b/fastapi_hypermodel/hal/hal_response.py @@ -160,3 +160,9 @@ def _validate( def render(self: Self, content: Any) -> bytes: self._validate(content) return super().render(content) + + +def get_hal_link_href(response: Any, link_name: str) -> Union[HALForType, None]: + links = response.get("_links", {}) + link = links.get(link_name, {}) + return HALForType.model_validate(link) if link else None diff --git a/tests/integration/hal/test_hal_items.py b/tests/integration/hal/test_hal_items.py index 35a28c1..084c50e 100644 --- a/tests/integration/hal/test_hal_items.py +++ b/tests/integration/hal/test_hal_items.py @@ -4,7 +4,7 @@ from fastapi.testclient import TestClient from examples.hal import Item -from fastapi_hypermodel import get_hal_link_href +from fastapi_hypermodel import UrlType, get_hal_link_href @pytest.fixture() @@ -13,17 +13,17 @@ def item_uri() -> str: @pytest.fixture() -def find_uri_template(hal_client: TestClient, item_uri: str) -> str: +def find_uri_template(hal_client: TestClient, item_uri: str) -> UrlType: find_uri = get_hal_link_href(hal_client.get(item_uri).json(), "find") assert find_uri - return find_uri + return find_uri.href @pytest.fixture() -def update_uri_template(hal_client: TestClient, item_uri: str) -> str: +def update_uri_template(hal_client: TestClient, item_uri: str) -> UrlType: update_uri = get_hal_link_href(hal_client.get(item_uri).json(), "update") assert update_uri - return update_uri + return update_uri.href def test_items_content_type(hal_client: TestClient, item_uri: str) -> None: @@ -35,8 +35,9 @@ def test_items_content_type(hal_client: TestClient, item_uri: str) -> None: def test_get_items(hal_client: TestClient, item_uri: str) -> None: response = hal_client.get(item_uri).json() - self_uri = get_hal_link_href(response, "self") - assert self_uri == item_uri + self_link = get_hal_link_href(response, "self") + assert self_link + assert self_link.href == item_uri find_uri = response.get("_links", {}).get("find", {}) assert find_uri.get("templated") @@ -56,10 +57,11 @@ def test_get_item( find_uri = item.parse_uri(find_uri_template) item_response = hal_client.get(find_uri).json() - item_href = get_hal_link_href(item_response, "self") + item_hal_link = get_hal_link_href(item_response, "self") - assert item_uri in item_href - assert item.id_ in item_href + assert item_hal_link + assert item_uri in item_hal_link.href + assert item.id_ in item_hal_link.href assert item_response.get("id_") == item.id_ @@ -93,8 +95,10 @@ def test_update_item_from_update_uri( new_data = {"name": f"updated_{uuid.uuid4().hex}"} - update_uri = get_hal_link_href(before, "update") - response = hal_client.put(update_uri, json=new_data).json() + update_link = get_hal_link_href(before, "update") + assert update_link + + response = hal_client.put(update_link.href, json=new_data).json() assert response.get("name") == new_data.get("name") assert response.get("name") != before.get("name") diff --git a/tests/integration/hal/test_hal_people.py b/tests/integration/hal/test_hal_people.py index 2c84ef4..3ef09b0 100644 --- a/tests/integration/hal/test_hal_people.py +++ b/tests/integration/hal/test_hal_people.py @@ -5,7 +5,7 @@ from fastapi.testclient import TestClient from examples.hal import Person -from fastapi_hypermodel import get_hal_link_href +from fastapi_hypermodel import UrlType, get_hal_link_href @pytest.fixture() @@ -14,17 +14,17 @@ def people_uri() -> str: @pytest.fixture() -def find_uri_template(hal_client: TestClient, people_uri: str) -> str: +def find_uri_template(hal_client: TestClient, people_uri: str) -> UrlType: find_uri = get_hal_link_href(hal_client.get(people_uri).json(), "find") assert find_uri - return find_uri + return find_uri.href @pytest.fixture() -def update_uri_template(hal_client: TestClient, people_uri: str) -> str: +def update_uri_template(hal_client: TestClient, people_uri: str) -> UrlType: update_uri = get_hal_link_href(hal_client.get(people_uri).json(), "update") assert update_uri - return update_uri + return update_uri.href def test_people_content_type(hal_client: TestClient, people_uri: str) -> None: @@ -36,8 +36,9 @@ def test_people_content_type(hal_client: TestClient, people_uri: str) -> None: def test_get_people(hal_client: TestClient, people_uri: str) -> None: response = hal_client.get(people_uri).json() - self_uri = get_hal_link_href(response, "self") - assert self_uri == people_uri + self_link = get_hal_link_href(response, "self") + assert self_link + assert self_link.href == people_uri find_uri = response.get("_links", {}).get("find", {}) assert find_uri.get("templated") @@ -50,10 +51,11 @@ def test_get_person( find_uri = person.parse_uri(find_uri_template) person_response = hal_client.get(find_uri).json() - person_href = get_hal_link_href(person_response, "self") + self_link = get_hal_link_href(person_response, "self") - assert people_uri in person_href - assert person.id_ in person_href + assert self_link + assert people_uri in self_link.href + assert person.id_ in self_link.href assert person_response.get("id_") == person.id_ embedded = person_response.get("_embedded") @@ -93,8 +95,9 @@ def test_update_person_from_update_uri( new_data = {"name": f"updated_{uuid.uuid4().hex}"} - update_uri = get_hal_link_href(before, "update") - response = hal_client.put(update_uri, json=new_data).json() + update_link = get_hal_link_href(before, "update") + assert update_link + response = hal_client.put(update_link.href, json=new_data).json() assert response.get("name") == new_data.get("name") assert response.get("name") != before.get("name") @@ -117,8 +120,9 @@ def test_get_person_items( assert isinstance(person_items, list) first_item, *_ = person_items - first_item_uri = get_hal_link_href(first_item, "self") - first_item_response = hal_client.get(first_item_uri).json() + first_item_link = get_hal_link_href(first_item, "self") + assert first_item_link + first_item_response = hal_client.get(first_item_link.href).json() assert first_item == first_item_response @@ -142,11 +146,11 @@ def test_add_item_to_unlocked_person( find_uri = unlocked_person.parse_uri(find_uri_template) before = hal_client.get(find_uri).json() before_items = before.get("_embedded", {}).get("sc:items", []) - add_item_uri = get_hal_link_href(before, "add_item") + add_item_link = get_hal_link_href(before, "add_item") - assert add_item_uri + assert add_item_link - after = hal_client.put(add_item_uri, json=existing_item).json() + after = hal_client.put(add_item_link.href, json=existing_item).json() after_items = after.get("_embedded", {}).get("sc:items", []) assert after_items @@ -165,11 +169,11 @@ def test_add_item_to_unlocked_person_nonexisting_item( ) -> None: find_uri = unlocked_person.parse_uri(find_uri_template) before = hal_client.get(find_uri).json() - add_item_uri = get_hal_link_href(before, "add_item") + add_item_link = get_hal_link_href(before, "add_item") - assert add_item_uri + assert add_item_link - response = hal_client.put(add_item_uri, json=non_existing_item) + response = hal_client.put(add_item_link.href, json=non_existing_item) assert response.status_code == 404 assert response.json() == {"detail": "No item found with id item05"} diff --git a/tests/test_utility_functions.py b/tests/test_utility_functions.py index b095e6a..8bb1862 100644 --- a/tests/test_utility_functions.py +++ b/tests/test_utility_functions.py @@ -113,14 +113,14 @@ def test_get_hal_link_href(hal_response: Any) -> None: actual = get_hal_link_href(hal_response, "self") expected = "/self" - assert actual == expected + assert actual + assert actual.href == expected def test_get_hal_link_href_not_found(hal_response: Any) -> None: actual = get_hal_link_href(hal_response, "update") - expected = "" - assert actual == expected + assert not actual class MockModel(HyperModel): From 5b87a91ca251bd69bbe3e0c4e8b8b29943eb462e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 08:56:09 -0300 Subject: [PATCH 42/66] Fix inconsistent class names in HAL --- examples/hal/app.py | 14 +++++++------- fastapi_hypermodel/__init__.py | 4 ++-- fastapi_hypermodel/hal/__init__.py | 4 ++-- fastapi_hypermodel/hal/hal_hypermodel.py | 6 +++--- tests/integration/hal/conftest.py | 6 +++--- tests/test_hal.py | 24 ++++++++++++------------ 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/examples/hal/app.py b/examples/hal/app.py index 876bf02..9307304 100644 --- a/examples/hal/app.py +++ b/examples/hal/app.py @@ -10,13 +10,13 @@ from fastapi_hypermodel import ( FrozenDict, HALFor, - HalHyperModel, + HALHyperModel, HALResponse, ) from fastapi_hypermodel.hal.hal_hypermodel import HALLinks -class ItemSummary(HalHyperModel): +class ItemSummary(HALHyperModel): name: str id_: str @@ -41,7 +41,7 @@ class ItemCreate(ItemUpdate): id_: str -class ItemCollection(HalHyperModel): +class ItemCollection(HALHyperModel): items: Sequence[Item] = Field(alias="sc:items") links: HALLinks = FrozenDict({ @@ -51,7 +51,7 @@ class ItemCollection(HalHyperModel): }) -class Person(HalHyperModel): +class Person(HALHyperModel): name: str id_: str is_locked: bool @@ -70,7 +70,7 @@ class Person(HalHyperModel): }) -class PersonCollection(HalHyperModel): +class PersonCollection(HALHyperModel): people: Sequence[Person] links: HALLinks = FrozenDict({ @@ -92,8 +92,8 @@ class PersonUpdate(BaseModel): app = FastAPI() -HalHyperModel.init_app(app) -HalHyperModel.register_curies(curies) +HALHyperModel.init_app(app) +HALHyperModel.register_curies(curies) @app.get("/items", response_model=ItemCollection, response_class=HALResponse) diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index 3c1efbc..d2dad51 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -13,7 +13,7 @@ FrozenDict, HALFor, HALForType, - HalHyperModel, + HALHyperModel, HALLinks, HALResponse, get_hal_link_href, @@ -38,9 +38,9 @@ "FrozenDict", "HALFor", "HALForType", + "HALHyperModel", "HALLinks", "HALResponse", - "HalHyperModel", "HasName", "HyperModel", "InvalidAttribute", diff --git a/fastapi_hypermodel/hal/__init__.py b/fastapi_hypermodel/hal/__init__.py index c445a42..1750dc3 100644 --- a/fastapi_hypermodel/hal/__init__.py +++ b/fastapi_hypermodel/hal/__init__.py @@ -1,12 +1,12 @@ -from .hal_hypermodel import FrozenDict, HALFor, HALForType, HalHyperModel, HALLinks +from .hal_hypermodel import FrozenDict, HALFor, HALForType, HALHyperModel, HALLinks from .hal_response import HALResponse, get_hal_link_href __all__ = [ "FrozenDict", "HALFor", "HALForType", + "HALHyperModel", "HALLinks", "HALResponse", - "HalHyperModel", "get_hal_link_href", ] diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index e2a684d..0a9852e 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -162,7 +162,7 @@ def __get_pydantic_core_schema__( HALLinks = Annotated[Union[FrozenDict, None], Field(alias="_links")] -class HalHyperModel(HyperModel): +class HALHyperModel(HyperModel): curies_: ClassVar[Optional[Sequence[HALForType]]] = None links: HALLinks = None embedded: Mapping[str, Union[Self, Sequence[Self]]] = Field( @@ -204,7 +204,7 @@ def add_links(self: Self) -> Self: first_link, *_ = valid_links validated_links[link_name] = valid_links if is_sequence else first_link - validated_links["curies"] = HalHyperModel.curies() # type: ignore + validated_links["curies"] = HALHyperModel.curies() # type: ignore self.links = frozendict(validated_links) @@ -218,7 +218,7 @@ def add_hypermodels_to_embedded(self: Self) -> Self: field if isinstance(field, Sequence) else [field] ) - if not all(isinstance(element, HalHyperModel) for element in value): + if not all(isinstance(element, HALHyperModel) for element in value): continue key = self.model_fields[name].alias or name diff --git a/tests/integration/hal/conftest.py b/tests/integration/hal/conftest.py index 8632c32..a7e42b4 100644 --- a/tests/integration/hal/conftest.py +++ b/tests/integration/hal/conftest.py @@ -15,13 +15,13 @@ from examples.hal import ( people as people_, ) -from fastapi_hypermodel import HalHyperModel +from fastapi_hypermodel import HALHyperModel @pytest.fixture() def hal_client() -> TestClient: - HalHyperModel.init_app(app) - HalHyperModel.register_curies(curies) + HALHyperModel.init_app(app) + HALHyperModel.register_curies(curies) return TestClient(app=app, base_url="http://haltestserver") diff --git a/tests/test_hal.py b/tests/test_hal.py index e755d5d..0024b11 100644 --- a/tests/test_hal.py +++ b/tests/test_hal.py @@ -11,14 +11,14 @@ FrozenDict, HALFor, HALForType, - HalHyperModel, + HALHyperModel, HALLinks, HALResponse, UrlType, ) -class MockClass(HalHyperModel): +class MockClass(HALHyperModel): id_: str links: HALLinks = FrozenDict({ @@ -26,38 +26,38 @@ class MockClass(HalHyperModel): }) -class MockClassWithEmbedded(HalHyperModel): +class MockClassWithEmbedded(HALHyperModel): id_: str test: MockClass -class MockClassWithMultipleEmbedded(HalHyperModel): +class MockClassWithMultipleEmbedded(HALHyperModel): id_: str test: MockClass test2: MockClass -class MockClassWithEmbeddedAliased(HalHyperModel): +class MockClassWithEmbeddedAliased(HALHyperModel): id_: str test: MockClass = Field(alias="sc:test") -class MockClassWithEmbeddedList(HalHyperModel): +class MockClassWithEmbeddedList(HALHyperModel): id_: str test: Sequence[MockClass] -class MockClassWithEmbeddedListAliased(HalHyperModel): +class MockClassWithEmbeddedListAliased(HALHyperModel): id_: str test: Sequence[MockClass] = Field(alias="sc:test") -class MockClassWithCuries(HalHyperModel): +class MockClassWithCuries(HALHyperModel): id_: str links: HALLinks = FrozenDict({ @@ -66,7 +66,7 @@ class MockClassWithCuries(HalHyperModel): }) -class MockClassWithMissingCuries(HalHyperModel): +class MockClassWithMissingCuries(HALHyperModel): id_: str links: HALLinks = FrozenDict({ @@ -81,7 +81,7 @@ def hal_app(app: FastAPI) -> FastAPI: def mock_read_with_path_hal() -> Any: # pragma: no cover return {} - HalHyperModel.init_app(app) + HALHyperModel.init_app(app) return app @@ -213,9 +213,9 @@ def curies() -> List[HALForType]: @pytest.fixture() def _set_curies(curies: Sequence[HALForType]) -> Generator[None, None, None]: - HalHyperModel.register_curies(curies) + HALHyperModel.register_curies(curies) yield - HalHyperModel.register_curies([]) + HALHyperModel.register_curies([]) @pytest.fixture() From 07639d40d563da3249b51f6a5de31be51e259cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 09:09:02 -0300 Subject: [PATCH 43/66] Use consistent approach in URLFor and HAL --- examples/url_for/app.py | 8 +++--- fastapi_hypermodel/__init__.py | 4 +-- fastapi_hypermodel/hal/__init__.py | 4 +-- fastapi_hypermodel/hal/hal_hypermodel.py | 6 ++--- fastapi_hypermodel/hal/hal_response.py | 2 +- fastapi_hypermodel/url_for/url_for.py | 32 ++++++++++++++---------- tests/integration/hal/test_hal_items.py | 20 +++++++-------- tests/integration/hal/test_hal_people.py | 28 ++++++++++----------- tests/test_hal.py | 7 ++++-- tests/test_url_for.py | 13 ++++++---- tests/test_utility_functions.py | 6 ++--- 11 files changed, 71 insertions(+), 59 deletions(-) diff --git a/examples/url_for/app.py b/examples/url_for/app.py index a7b0ec8..0e0998e 100644 --- a/examples/url_for/app.py +++ b/examples/url_for/app.py @@ -36,8 +36,8 @@ class ItemCollection(HyperModel): items: Sequence[Item] href: UrlFor = UrlFor("read_items") - find: UrlFor = UrlFor("read_item", template=True) - update: UrlFor = UrlFor("update_item", template=True) + find: UrlFor = UrlFor("read_item", templated=True) + update: UrlFor = UrlFor("update_item", templated=True) class Person(HyperModel): @@ -64,8 +64,8 @@ class PeopleCollection(HyperModel): people: Sequence[Person] href: UrlFor = UrlFor("read_people") - find: UrlFor = UrlFor("read_person", template=True) - update: UrlFor = UrlFor("update_person", template=True) + find: UrlFor = UrlFor("read_person", templated=True) + update: UrlFor = UrlFor("update_person", templated=True) app = FastAPI() diff --git a/fastapi_hypermodel/__init__.py b/fastapi_hypermodel/__init__.py index d2dad51..1d4fb8c 100644 --- a/fastapi_hypermodel/__init__.py +++ b/fastapi_hypermodel/__init__.py @@ -16,7 +16,7 @@ HALHyperModel, HALLinks, HALResponse, - get_hal_link_href, + get_hal_link, ) from .siren import ( SirenActionFor, @@ -55,7 +55,7 @@ "UrlFor", "UrlType", "extract_value_by_name", - "get_hal_link_href", + "get_hal_link", "get_route_from_app", "get_siren_action", "get_siren_link", diff --git a/fastapi_hypermodel/hal/__init__.py b/fastapi_hypermodel/hal/__init__.py index 1750dc3..38dad3f 100644 --- a/fastapi_hypermodel/hal/__init__.py +++ b/fastapi_hypermodel/hal/__init__.py @@ -1,5 +1,5 @@ from .hal_hypermodel import FrozenDict, HALFor, HALForType, HALHyperModel, HALLinks -from .hal_response import HALResponse, get_hal_link_href +from .hal_response import HALResponse, get_hal_link __all__ = [ "FrozenDict", @@ -8,5 +8,5 @@ "HALHyperModel", "HALLinks", "HALResponse", - "get_hal_link_href", + "get_hal_link", ] diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index 0a9852e..18418fe 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -108,12 +108,12 @@ def _get_uri_path( def __call__( self: Self, app: Optional[Starlette], values: Mapping[str, Any] - ) -> HALForType: + ) -> Optional[HALForType]: if app is None: - return HALForType() + return None if self._condition and not self._condition(values): - return HALForType() + return None route = get_route_from_app(app, self._endpoint) diff --git a/fastapi_hypermodel/hal/hal_response.py b/fastapi_hypermodel/hal/hal_response.py index eeecbab..ddda1d9 100644 --- a/fastapi_hypermodel/hal/hal_response.py +++ b/fastapi_hypermodel/hal/hal_response.py @@ -162,7 +162,7 @@ def render(self: Self, content: Any) -> bytes: return super().render(content) -def get_hal_link_href(response: Any, link_name: str) -> Union[HALForType, None]: +def get_hal_link(response: Any, link_name: str) -> Union[HALForType, None]: links = response.get("_links", {}) link = links.get(link_name, {}) return HALForType.model_validate(link) if link else None diff --git a/fastapi_hypermodel/url_for/url_for.py b/fastapi_hypermodel/url_for/url_for.py index 9f5013f..299973c 100644 --- a/fastapi_hypermodel/url_for/url_for.py +++ b/fastapi_hypermodel/url_for/url_for.py @@ -16,6 +16,7 @@ from pydantic.json_schema import JsonSchemaValue from pydantic_core import CoreSchema from starlette.applications import Starlette +from starlette.routing import Route from typing_extensions import Self from fastapi_hypermodel.base import ( @@ -40,14 +41,14 @@ class UrlFor(UrlForType, AbstractHyperField[UrlForType]): _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() - _template: Optional[bool] = PrivateAttr() + _templated: Optional[bool] = PrivateAttr() def __init__( self: Self, endpoint: Union[HasName, str], param_values: Optional[Mapping[str, Any]] = None, condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, - template: Optional[bool] = None, + templated: Optional[bool] = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -56,7 +57,7 @@ def __init__( ) self._param_values = param_values or {} self._condition = condition - self._template = template + self._templated = templated @classmethod def __get_pydantic_json_schema__( @@ -72,23 +73,28 @@ def __get_pydantic_json_schema__( return json_schema + def _get_uri_path( + self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] + ) -> UrlType: + if self._templated and isinstance(route, Route): + return UrlType(route.path) + + params = resolve_param_values(self._param_values, values) + return UrlType(app.url_path_for(self._endpoint, **params)) + def __call__( self: Self, app: Optional[Starlette], values: Mapping[str, Any], - ) -> UrlForType: + ) -> Optional[UrlForType]: if app is None: - return UrlForType() + return None if self._condition and not self._condition(values): - return UrlForType() - - if not self._template: - resolved_params = resolve_param_values(self._param_values, values) - uri_for = app.url_path_for(self._endpoint, **resolved_params) - return UrlForType(hypermedia=UrlType(uri_for)) + return None route = get_route_from_app(app, self._endpoint) - href = UrlType(route.path) - return UrlForType(hypermedia=href) + uri_path = self._get_uri_path(app, values, route) + + return UrlForType(hypermedia=uri_path) diff --git a/tests/integration/hal/test_hal_items.py b/tests/integration/hal/test_hal_items.py index 084c50e..8dc6b3b 100644 --- a/tests/integration/hal/test_hal_items.py +++ b/tests/integration/hal/test_hal_items.py @@ -4,7 +4,7 @@ from fastapi.testclient import TestClient from examples.hal import Item -from fastapi_hypermodel import UrlType, get_hal_link_href +from fastapi_hypermodel import UrlType, get_hal_link @pytest.fixture() @@ -14,14 +14,14 @@ def item_uri() -> str: @pytest.fixture() def find_uri_template(hal_client: TestClient, item_uri: str) -> UrlType: - find_uri = get_hal_link_href(hal_client.get(item_uri).json(), "find") + find_uri = get_hal_link(hal_client.get(item_uri).json(), "find") assert find_uri return find_uri.href @pytest.fixture() def update_uri_template(hal_client: TestClient, item_uri: str) -> UrlType: - update_uri = get_hal_link_href(hal_client.get(item_uri).json(), "update") + update_uri = get_hal_link(hal_client.get(item_uri).json(), "update") assert update_uri return update_uri.href @@ -35,7 +35,7 @@ def test_items_content_type(hal_client: TestClient, item_uri: str) -> None: def test_get_items(hal_client: TestClient, item_uri: str) -> None: response = hal_client.get(item_uri).json() - self_link = get_hal_link_href(response, "self") + self_link = get_hal_link(response, "self") assert self_link assert self_link.href == item_uri @@ -57,7 +57,7 @@ def test_get_item( find_uri = item.parse_uri(find_uri_template) item_response = hal_client.get(find_uri).json() - item_hal_link = get_hal_link_href(item_response, "self") + item_hal_link = get_hal_link(item_response, "self") assert item_hal_link assert item_uri in item_hal_link.href @@ -81,8 +81,8 @@ def test_update_item_from_uri_template( assert response.get("name") == new_data.get("name") assert response.get("name") != before.get("name") - before_uri = get_hal_link_href(before, "self") - after_uri = get_hal_link_href(response, "self") + before_uri = get_hal_link(before, "self") + after_uri = get_hal_link(response, "self") assert before_uri == after_uri @@ -95,7 +95,7 @@ def test_update_item_from_update_uri( new_data = {"name": f"updated_{uuid.uuid4().hex}"} - update_link = get_hal_link_href(before, "update") + update_link = get_hal_link(before, "update") assert update_link response = hal_client.put(update_link.href, json=new_data).json() @@ -103,7 +103,7 @@ def test_update_item_from_update_uri( assert response.get("name") == new_data.get("name") assert response.get("name") != before.get("name") - before_uri = get_hal_link_href(before, "self") - after_uri = get_hal_link_href(response, "self") + before_uri = get_hal_link(before, "self") + after_uri = get_hal_link(response, "self") assert before_uri == after_uri diff --git a/tests/integration/hal/test_hal_people.py b/tests/integration/hal/test_hal_people.py index 3ef09b0..eb8127a 100644 --- a/tests/integration/hal/test_hal_people.py +++ b/tests/integration/hal/test_hal_people.py @@ -5,7 +5,7 @@ from fastapi.testclient import TestClient from examples.hal import Person -from fastapi_hypermodel import UrlType, get_hal_link_href +from fastapi_hypermodel import UrlType, get_hal_link @pytest.fixture() @@ -15,14 +15,14 @@ def people_uri() -> str: @pytest.fixture() def find_uri_template(hal_client: TestClient, people_uri: str) -> UrlType: - find_uri = get_hal_link_href(hal_client.get(people_uri).json(), "find") + find_uri = get_hal_link(hal_client.get(people_uri).json(), "find") assert find_uri return find_uri.href @pytest.fixture() def update_uri_template(hal_client: TestClient, people_uri: str) -> UrlType: - update_uri = get_hal_link_href(hal_client.get(people_uri).json(), "update") + update_uri = get_hal_link(hal_client.get(people_uri).json(), "update") assert update_uri return update_uri.href @@ -36,7 +36,7 @@ def test_people_content_type(hal_client: TestClient, people_uri: str) -> None: def test_get_people(hal_client: TestClient, people_uri: str) -> None: response = hal_client.get(people_uri).json() - self_link = get_hal_link_href(response, "self") + self_link = get_hal_link(response, "self") assert self_link assert self_link.href == people_uri @@ -51,7 +51,7 @@ def test_get_person( find_uri = person.parse_uri(find_uri_template) person_response = hal_client.get(find_uri).json() - self_link = get_hal_link_href(person_response, "self") + self_link = get_hal_link(person_response, "self") assert self_link assert people_uri in self_link.href @@ -81,8 +81,8 @@ def test_update_person_from_uri_template( assert response.get("name") == new_data.get("name") assert response.get("name") != before.get("name") - before_uri = get_hal_link_href(before, "self") - after_uri = get_hal_link_href(response, "self") + before_uri = get_hal_link(before, "self") + after_uri = get_hal_link(response, "self") assert before_uri == after_uri @@ -95,15 +95,15 @@ def test_update_person_from_update_uri( new_data = {"name": f"updated_{uuid.uuid4().hex}"} - update_link = get_hal_link_href(before, "update") + update_link = get_hal_link(before, "update") assert update_link response = hal_client.put(update_link.href, json=new_data).json() assert response.get("name") == new_data.get("name") assert response.get("name") != before.get("name") - before_uri = get_hal_link_href(before, "self") - after_uri = get_hal_link_href(response, "self") + before_uri = get_hal_link(before, "self") + after_uri = get_hal_link(response, "self") assert before_uri == after_uri @@ -120,7 +120,7 @@ def test_get_person_items( assert isinstance(person_items, list) first_item, *_ = person_items - first_item_link = get_hal_link_href(first_item, "self") + first_item_link = get_hal_link(first_item, "self") assert first_item_link first_item_response = hal_client.get(first_item_link.href).json() @@ -146,7 +146,7 @@ def test_add_item_to_unlocked_person( find_uri = unlocked_person.parse_uri(find_uri_template) before = hal_client.get(find_uri).json() before_items = before.get("_embedded", {}).get("sc:items", []) - add_item_link = get_hal_link_href(before, "add_item") + add_item_link = get_hal_link(before, "add_item") assert add_item_link @@ -169,7 +169,7 @@ def test_add_item_to_unlocked_person_nonexisting_item( ) -> None: find_uri = unlocked_person.parse_uri(find_uri_template) before = hal_client.get(find_uri).json() - add_item_link = get_hal_link_href(before, "add_item") + add_item_link = get_hal_link(before, "add_item") assert add_item_link @@ -185,6 +185,6 @@ def test_add_item_to_locked_person( ) -> None: find_uri = locked_person.parse_uri(find_uri_template) before = hal_client.get(find_uri).json() - add_item_uri = get_hal_link_href(before, "add_item") + add_item_uri = get_hal_link(before, "add_item") assert not add_item_uri diff --git a/tests/test_hal.py b/tests/test_hal.py index 0024b11..48224b5 100644 --- a/tests/test_hal.py +++ b/tests/test_hal.py @@ -443,7 +443,7 @@ def test_hal_for_no_app() -> None: hal_for = HALFor("mock_read_with_path_hal", {"id_": ""}) hypermedia = hal_for(None, vars(mock)) - assert hypermedia.href == "" + assert hypermedia is None def test_build_hypermedia_passing_condition(app: FastAPI) -> None: @@ -454,6 +454,7 @@ def test_build_hypermedia_passing_condition(app: FastAPI) -> None: condition=lambda values: values["locked"], ) uri = hal_for(app, {"id_": sample_id, "locked": True}) + assert uri assert uri.href == f"/mock_read/{sample_id}" @@ -463,6 +464,7 @@ def test_build_hypermedia_template(hal_app: FastAPI) -> None: templated=True, ) uri = hal_for(hal_app, {}) + assert uri assert uri.href == "/mock_read/{id_}" @@ -474,7 +476,7 @@ def test_build_hypermedia_not_passing_condition(hal_app: FastAPI) -> None: condition=lambda values: values["locked"], ) uri = hal_for(hal_app, {"id_": sample_id, "locked": False}) - assert uri.href == "" + assert uri is None def test_build_hypermedia_with_href(app: FastAPI) -> None: @@ -485,6 +487,7 @@ def test_build_hypermedia_with_href(app: FastAPI) -> None: condition=lambda values: values["locked"], ) uri = hal_for(app, {"id_": sample_id, "locked": True}) + assert uri assert uri.href == f"/mock_read/{sample_id}" diff --git a/tests/test_url_for.py b/tests/test_url_for.py index aa11ab3..f952759 100644 --- a/tests/test_url_for.py +++ b/tests/test_url_for.py @@ -19,13 +19,14 @@ def test_build_hypermedia_with_endpoint(app: FastAPI, endpoint: str) -> None: sample_id = "test" url_for = UrlFor(endpoint, {"id_": ""}) uri = url_for(app, {"id_": sample_id}) + assert uri assert uri.hypermedia == f"/mock_read/{sample_id}" def test_build_hypermedia_no_app() -> None: url_for = UrlFor("mock_read_with_path", {"id_": ""}) uri = url_for(None, {}) - assert uri.hypermedia is None + assert uri is None def test_build_hypermedia_passing_condition(app: FastAPI) -> None: @@ -37,6 +38,7 @@ def test_build_hypermedia_passing_condition(app: FastAPI) -> None: condition=lambda values: values["locked"], ) uri = url_for(app, {"id_": sample_id, "locked": locked}) + assert uri assert uri.hypermedia == f"/mock_read/{sample_id}" @@ -49,22 +51,23 @@ def test_build_hypermedia_not_passing_condition(app: FastAPI) -> None: condition=lambda values: values["locked"], ) uri = url_for(app, {"id_": sample_id, "locked": locked}) - assert uri.hypermedia is None + assert uri is None def test_build_hypermedia_template(app: FastAPI) -> None: url_for = UrlFor( "mock_read_with_path", - template=True, + templated=True, ) uri = url_for(app, {}) + assert uri assert uri.hypermedia == "/mock_read/{id_}" def test_json_serialization(app: FastAPI) -> None: url_for = UrlFor( "mock_read_with_path", - template=True, + templated=True, ) rendered_url = url_for(app, {}) assert rendered_url @@ -76,7 +79,7 @@ def test_json_serialization(app: FastAPI) -> None: def test_json_serialization_no_build() -> None: url_for = UrlFor( "mock_read_with_path", - template=True, + templated=True, ) uri = url_for.model_dump() diff --git a/tests/test_utility_functions.py b/tests/test_utility_functions.py index 8bb1862..f71a693 100644 --- a/tests/test_utility_functions.py +++ b/tests/test_utility_functions.py @@ -8,7 +8,7 @@ HyperModel, InvalidAttribute, extract_value_by_name, - get_hal_link_href, + get_hal_link, get_route_from_app, resolve_param_values, ) @@ -110,7 +110,7 @@ def test_extract_value_by_name_invalid() -> None: def test_get_hal_link_href(hal_response: Any) -> None: - actual = get_hal_link_href(hal_response, "self") + actual = get_hal_link(hal_response, "self") expected = "/self" assert actual @@ -118,7 +118,7 @@ def test_get_hal_link_href(hal_response: Any) -> None: def test_get_hal_link_href_not_found(hal_response: Any) -> None: - actual = get_hal_link_href(hal_response, "update") + actual = get_hal_link(hal_response, "update") assert not actual From b8c97bde04c417b72b5ddc13ed25562c1f262db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 09:09:49 -0300 Subject: [PATCH 44/66] Remove unused code, legacy of LinkSet --- fastapi_hypermodel/base/hypermodel.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/fastapi_hypermodel/base/hypermodel.py b/fastapi_hypermodel/base/hypermodel.py index 9312877..4e35581 100644 --- a/fastapi_hypermodel/base/hypermodel.py +++ b/fastapi_hypermodel/base/hypermodel.py @@ -39,32 +39,6 @@ class HasName(Protocol): class AbstractHyperField(ABC, Generic[T]): - @classmethod - def __get_pydantic_core_schema__( - cls: Type[Self], *_: Any - ) -> pydantic_core.CoreSchema: - return pydantic_core.core_schema.any_schema() - - @classmethod - def __schema_subclasses__( - cls: Type[Self], caller_class: Optional[Type[Self]] = None - ) -> List[Dict[str, Any]]: - subclasses_schemas: List[Dict[str, Any]] = [] - for subclass in cls.__subclasses__(): - if caller_class and issubclass(subclass, caller_class): - continue - - if not issubclass(subclass, BaseModel): - continue - - schema = subclass.model_json_schema() - schema_dict = json.dumps(schema) - deref_schema: Dict[str, Any] = jsonref.loads(schema_dict) - - subclasses_schemas.append(deref_schema) - - return subclasses_schemas - @abstractmethod def __call__( self: Self, app: Optional[Starlette], values: Mapping[str, Any] From 72f48e48259742c42f15b96c4dfc08feb29fd509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 09:16:31 -0300 Subject: [PATCH 45/66] Fix formatting warnings --- fastapi_hypermodel/base/hypermodel.py | 11 ++++------- fastapi_hypermodel/hal/hal_hypermodel.py | 7 ++++--- fastapi_hypermodel/siren/siren_action.py | 10 +++++----- fastapi_hypermodel/siren/siren_link.py | 4 ++-- pyproject.toml | 2 +- 5 files changed, 16 insertions(+), 18 deletions(-) diff --git a/fastapi_hypermodel/base/hypermodel.py b/fastapi_hypermodel/base/hypermodel.py index 4e35581..4d0bb7d 100644 --- a/fastapi_hypermodel/base/hypermodel.py +++ b/fastapi_hypermodel/base/hypermodel.py @@ -1,4 +1,3 @@ -import json from abc import ABC, abstractmethod from string import Formatter from typing import ( @@ -18,8 +17,6 @@ runtime_checkable, ) -import jsonref -import pydantic_core from pydantic import ( BaseModel, model_validator, @@ -46,7 +43,7 @@ def __call__( raise NotImplementedError -T2 = TypeVar("T2", bound=Callable[..., Any]) +R = TypeVar("R", bound=Callable[..., Any]) class HyperModel(BaseModel): @@ -100,9 +97,9 @@ def parse_uri(self: Self, uri_template: str) -> str: return self._parse_uri(self, uri_template) def _validate_factory( - self: Self, elements: Sequence[T2], properties: Mapping[str, str] - ) -> List[T2]: - validated_elements: List[T2] = [] + self: Self, elements: Sequence[R], properties: Mapping[str, str] + ) -> List[R]: + validated_elements: List[R] = [] for element_factory in elements: if not callable(element_factory): validated_elements.append(element_factory) diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index 18418fe..d24fe29 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -195,7 +195,7 @@ def add_links(self: Self) -> Self: for link_name, link_ in links.items(): is_sequence = isinstance(link_, Sequence) - link_sequence: Sequence[HALFor] = link_ if is_sequence else [link_] + link_sequence = link_ if is_sequence else [link_] valid_links = self._validate_factory(link_sequence, vars(self)) if not valid_links: @@ -206,7 +206,7 @@ def add_links(self: Self) -> Self: validated_links["curies"] = HALHyperModel.curies() # type: ignore - self.links = frozendict(validated_links) + self.links = FrozenDict(validated_links) return self @@ -233,7 +233,8 @@ def add_hypermodels_to_embedded(self: Self) -> Self: return self @field_serializer("links") - def serialize_links(self: Self, links: HALLinks) -> Dict[str, HALLinkType]: + @staticmethod + def serialize_links(links: HALLinks) -> Dict[str, HALLinkType]: if not links: return {} return dict(links.items()) diff --git a/fastapi_hypermodel/siren/siren_action.py b/fastapi_hypermodel/siren/siren_action.py index 0a4e5a6..2bdc3a6 100644 --- a/fastapi_hypermodel/siren/siren_action.py +++ b/fastapi_hypermodel/siren/siren_action.py @@ -68,8 +68,8 @@ class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): # p # For details on the folllowing fields, check https://github.com/kevinswiber/siren _class: Union[Sequence[str], None] = PrivateAttr() _title: Union[str, None] = PrivateAttr() - _name: Union[str, None] = PrivateAttr() - _method: Union[str, None] = PrivateAttr() + _name: str = PrivateAttr() + _method: str = PrivateAttr() _type: Union[str, None] = PrivateAttr() _fields: Union[Sequence[SirenFieldType], None] = PrivateAttr() @@ -84,8 +84,8 @@ def __init__( type_: Union[str, None] = None, class_: Union[Sequence[str], None] = None, fields: Union[Sequence[SirenFieldType], None] = None, - method: Union[str, None] = None, - name: Union[str, None] = "", + method: str = "GET", + name: str = "", **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -153,7 +153,7 @@ def __call__( route = get_route_from_app(app, self._endpoint) if not self._method: - self._method = next(iter(route.methods or {}), None) + self._method = next(iter(route.methods or {}), "GET") uri_path = self._get_uri_path(app, values, route) diff --git a/fastapi_hypermodel/siren/siren_link.py b/fastapi_hypermodel/siren/siren_link.py index b7df5cc..6620f85 100644 --- a/fastapi_hypermodel/siren/siren_link.py +++ b/fastapi_hypermodel/siren/siren_link.py @@ -111,6 +111,6 @@ def __call__( href=uri_path, rel=self._rel, title=self._title, - type_=self._type, # type: ignore - class_=self._class, # type: ignore + type_=self._type, + class_=self._class, ) diff --git a/pyproject.toml b/pyproject.toml index 66366b1..8597a02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -222,7 +222,7 @@ check-str-concat-over-line-jumps = true ignore-comments = true ignore-docstrings = true ignore-signatures = true -min-similarity-lines = 7 +min-similarity-lines = 10 [tool.pylint.variables] allow-global-unused-variables = false From dda77702b56ce81fd56f88996951d2001dff9ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 13:23:30 -0300 Subject: [PATCH 46/66] Fix bug for non-get endpoints in SIREN --- fastapi_hypermodel/siren/siren_action.py | 4 ++-- tests/test_siren.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/fastapi_hypermodel/siren/siren_action.py b/fastapi_hypermodel/siren/siren_action.py index 2bdc3a6..2e66655 100644 --- a/fastapi_hypermodel/siren/siren_action.py +++ b/fastapi_hypermodel/siren/siren_action.py @@ -69,7 +69,7 @@ class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): # p _class: Union[Sequence[str], None] = PrivateAttr() _title: Union[str, None] = PrivateAttr() _name: str = PrivateAttr() - _method: str = PrivateAttr() + _method: Union[str, None] = PrivateAttr() _type: Union[str, None] = PrivateAttr() _fields: Union[Sequence[SirenFieldType], None] = PrivateAttr() @@ -84,7 +84,7 @@ def __init__( type_: Union[str, None] = None, class_: Union[Sequence[str], None] = None, fields: Union[Sequence[SirenFieldType], None] = None, - method: str = "GET", + method: Union[str, None] = None, name: str = "", **kwargs: Any, ) -> None: diff --git a/tests/test_siren.py b/tests/test_siren.py index 301a9c6..8b88669 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -54,6 +54,10 @@ def mock_read_with_path_siren_with_hypermodel( ) -> Any: # pragma: no cover return mock.model_dump() + @app.post("siren_with_post", response_class=SirenResponse) + def mock_read_with_path_siren_with_post() -> Any: # pragma: no cover + return {} + SirenHyperModel.init_app(app) return app @@ -281,6 +285,22 @@ def test_siren_action_for(siren_app: FastAPI) -> None: assert not siren_action_for_type.fields +def test_siren_action_for_with_non_get(siren_app: FastAPI) -> None: + mock = MockClass(id_="test") + + siren_action_for = SirenActionFor( + "mock_read_with_path_siren_with_post", name="test" + ) + assert mock.properties + siren_action_for_type = siren_action_for(siren_app, mock.properties) + + assert isinstance(siren_action_for_type, SirenActionType) + assert siren_action_for_type.href == "siren_with_post" + assert siren_action_for_type.name == "test" + assert siren_action_for_type.method == "POST" + assert not siren_action_for_type.fields + + def test_siren_action_for_serialize(siren_app: FastAPI) -> None: mock = MockClass(id_="test") From b331347d5db1898276957adc021f0afcafb4426f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 13:34:46 -0300 Subject: [PATCH 47/66] Fix mypy warning --- fastapi_hypermodel/base/hypermodel.py | 6 +++++- fastapi_hypermodel/hal/hal_hypermodel.py | 9 ++++----- fastapi_hypermodel/siren/siren_link.py | 4 ++-- pyproject.toml | 3 +++ 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/fastapi_hypermodel/base/hypermodel.py b/fastapi_hypermodel/base/hypermodel.py index 4d0bb7d..ccb711f 100644 --- a/fastapi_hypermodel/base/hypermodel.py +++ b/fastapi_hypermodel/base/hypermodel.py @@ -13,6 +13,7 @@ Sequence, Type, TypeVar, + Union, cast, runtime_checkable, ) @@ -97,8 +98,11 @@ def parse_uri(self: Self, uri_template: str) -> str: return self._parse_uri(self, uri_template) def _validate_factory( - self: Self, elements: Sequence[R], properties: Mapping[str, str] + self: Self, elements: Union[R, Sequence[R]], properties: Mapping[str, str] ) -> List[R]: + if not isinstance(elements, Sequence): + elements = [elements] + validated_elements: List[R] = [] for element_factory in elements: if not callable(element_factory): diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index d24fe29..a191994 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -193,16 +193,15 @@ def add_links(self: Self) -> Self: links = cast(Mapping[str, HALLinkType], value) for link_name, link_ in links.items(): - is_sequence = isinstance(link_, Sequence) - - link_sequence = link_ if is_sequence else [link_] - valid_links = self._validate_factory(link_sequence, vars(self)) + valid_links = self._validate_factory(link_, vars(self)) if not valid_links: continue first_link, *_ = valid_links - validated_links[link_name] = valid_links if is_sequence else first_link + validated_links[link_name] = ( + valid_links if isinstance(link_, Sequence) else first_link + ) validated_links["curies"] = HALHyperModel.curies() # type: ignore diff --git a/fastapi_hypermodel/siren/siren_link.py b/fastapi_hypermodel/siren/siren_link.py index 6620f85..b7df5cc 100644 --- a/fastapi_hypermodel/siren/siren_link.py +++ b/fastapi_hypermodel/siren/siren_link.py @@ -111,6 +111,6 @@ def __call__( href=uri_path, rel=self._rel, title=self._title, - type_=self._type, - class_=self._class, + type_=self._type, # type: ignore + class_=self._class, # type: ignore ) diff --git a/pyproject.toml b/pyproject.toml index 8597a02..394485c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,9 @@ exclude_also = [ [tool.mypy] strict = true ignore_missing_imports = true +disable_error_code = [ + "unused-ignore", # To use names in Pydantic Init +] ######################################### # Ruff From 30acd4f58a7e7a842fbae94984cd21383b13d06a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 14:13:17 -0300 Subject: [PATCH 48/66] Group common functionality in parent class --- fastapi_hypermodel/base/hypermodel.py | 20 +++++++++++++++++++- fastapi_hypermodel/hal/hal_hypermodel.py | 24 ++++++++++-------------- fastapi_hypermodel/siren/siren_action.py | 19 ++++++++----------- fastapi_hypermodel/siren/siren_link.py | 20 ++++++++------------ fastapi_hypermodel/url_for/url_for.py | 24 ++++++++++-------------- pyproject.toml | 2 +- 6 files changed, 56 insertions(+), 53 deletions(-) diff --git a/fastapi_hypermodel/base/hypermodel.py b/fastapi_hypermodel/base/hypermodel.py index ccb711f..b7d3ced 100644 --- a/fastapi_hypermodel/base/hypermodel.py +++ b/fastapi_hypermodel/base/hypermodel.py @@ -23,9 +23,11 @@ model_validator, ) from starlette.applications import Starlette +from starlette.routing import Route from typing_extensions import Self -from fastapi_hypermodel.base.utils import extract_value_by_name +from fastapi_hypermodel.base.url_type import UrlType +from fastapi_hypermodel.base.utils import extract_value_by_name, resolve_param_values @runtime_checkable @@ -43,6 +45,22 @@ def __call__( ) -> Optional[T]: raise NotImplementedError + @staticmethod + def _get_uri_path( + *, + templated: bool, + app: Starlette, + values: Mapping[str, Any], + route: Union[Route, str], + params: Mapping[str, str], + endpoint: str, + ) -> UrlType: + if templated and isinstance(route, Route): + return UrlType(route.path) + + params = resolve_param_values(params, values) + return UrlType(app.url_path_for(endpoint, **params)) + R = TypeVar("R", bound=Callable[..., Any]) diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index a191994..4535b49 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -23,7 +23,6 @@ model_validator, ) from starlette.applications import Starlette -from starlette.routing import Route from typing_extensions import Annotated, Self from fastapi_hypermodel.base import ( @@ -32,7 +31,6 @@ HyperModel, UrlType, get_route_from_app, - resolve_param_values, ) @@ -59,7 +57,7 @@ class HALFor(HALForType, AbstractHyperField[HALForType]): _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() - _templated: Optional[bool] = PrivateAttr() + _templated: bool = PrivateAttr() # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal _title: Optional[str] = PrivateAttr() _name: Optional[str] = PrivateAttr() @@ -74,7 +72,7 @@ def __init__( param_values: Optional[Mapping[str, str]] = None, description: Optional[str] = None, condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, - templated: Optional[bool] = None, + templated: bool = False, title: Optional[str] = None, name: Optional[str] = None, type_: Optional[str] = None, @@ -97,15 +95,6 @@ def __init__( self._profile = profile self._deprecation = deprecation - def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] - ) -> UrlType: - if self._templated and isinstance(route, Route): - return UrlType(route.path) - - params = resolve_param_values(self._param_values, values) - return UrlType(app.url_path_for(self._endpoint, **params)) - def __call__( self: Self, app: Optional[Starlette], values: Mapping[str, Any] ) -> Optional[HALForType]: @@ -117,7 +106,14 @@ def __call__( route = get_route_from_app(app, self._endpoint) - uri_path = self._get_uri_path(app, values, route) + uri_path = self._get_uri_path( + templated=self._templated, + endpoint=self._endpoint, + app=app, + values=values, + params=self._param_values, + route=route, + ) return HALForType( href=uri_path, diff --git a/fastapi_hypermodel/siren/siren_action.py b/fastapi_hypermodel/siren/siren_action.py index 2e66655..1cd1267 100644 --- a/fastapi_hypermodel/siren/siren_action.py +++ b/fastapi_hypermodel/siren/siren_action.py @@ -30,7 +30,6 @@ HasName, UrlType, get_route_from_app, - resolve_param_values, ) from .siren_base import SirenBase @@ -103,15 +102,6 @@ def __init__( self._name = name self._class = class_ - def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] - ) -> UrlType: - if self._templated and isinstance(route, Route): - return UrlType(route.path) - - params = resolve_param_values(self._param_values, values) - return UrlType(app.url_path_for(self._endpoint, **params)) - def _prepopulate_fields( self: Self, fields: Sequence[SirenFieldType], values: Mapping[str, Any] ) -> List[SirenFieldType]: @@ -155,7 +145,14 @@ def __call__( if not self._method: self._method = next(iter(route.methods or {}), "GET") - uri_path = self._get_uri_path(app, values, route) + uri_path = self._get_uri_path( + templated=self._templated, + endpoint=self._endpoint, + app=app, + values=values, + params=self._param_values, + route=route, + ) if not self._fields: self._fields = self._compute_fields(route, values) diff --git a/fastapi_hypermodel/siren/siren_link.py b/fastapi_hypermodel/siren/siren_link.py index b7df5cc..f4d6ea4 100644 --- a/fastapi_hypermodel/siren/siren_link.py +++ b/fastapi_hypermodel/siren/siren_link.py @@ -16,7 +16,6 @@ field_validator, ) from starlette.applications import Starlette -from starlette.routing import Route from typing_extensions import Self from fastapi_hypermodel.base import ( @@ -24,7 +23,6 @@ HasName, UrlType, get_route_from_app, - resolve_param_values, ) from .siren_base import SirenBase @@ -83,15 +81,6 @@ def __init__( self._rel = rel or [] self._class = class_ - def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] - ) -> UrlType: - if self._templated and isinstance(route, Route): - return UrlType(route.path) - - params = resolve_param_values(self._param_values, values) - return UrlType(app.url_path_for(self._endpoint, **params)) - def __call__( self: Self, app: Union[Starlette, None], values: Mapping[str, Any] ) -> Union[SirenLinkType, None]: @@ -104,7 +93,14 @@ def __call__( route = get_route_from_app(app, self._endpoint) properties = values.get("properties", values) - uri_path = self._get_uri_path(app, properties, route) + uri_path = self._get_uri_path( + templated=self._templated, + endpoint=self._endpoint, + app=app, + values=properties, + params=self._param_values, + route=route, + ) # Using model_validate to avoid conflicts with keyword class return SirenLinkType( diff --git a/fastapi_hypermodel/url_for/url_for.py b/fastapi_hypermodel/url_for/url_for.py index 299973c..fb46686 100644 --- a/fastapi_hypermodel/url_for/url_for.py +++ b/fastapi_hypermodel/url_for/url_for.py @@ -16,7 +16,6 @@ from pydantic.json_schema import JsonSchemaValue from pydantic_core import CoreSchema from starlette.applications import Starlette -from starlette.routing import Route from typing_extensions import Self from fastapi_hypermodel.base import ( @@ -25,7 +24,6 @@ HasName, UrlType, get_route_from_app, - resolve_param_values, ) @@ -41,14 +39,14 @@ class UrlFor(UrlForType, AbstractHyperField[UrlForType]): _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() - _templated: Optional[bool] = PrivateAttr() + _templated: bool = PrivateAttr() def __init__( self: Self, endpoint: Union[HasName, str], param_values: Optional[Mapping[str, Any]] = None, condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, - templated: Optional[bool] = None, + templated: bool = False, **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -73,15 +71,6 @@ def __get_pydantic_json_schema__( return json_schema - def _get_uri_path( - self: Self, app: Starlette, values: Mapping[str, Any], route: Union[Route, str] - ) -> UrlType: - if self._templated and isinstance(route, Route): - return UrlType(route.path) - - params = resolve_param_values(self._param_values, values) - return UrlType(app.url_path_for(self._endpoint, **params)) - def __call__( self: Self, app: Optional[Starlette], @@ -95,6 +84,13 @@ def __call__( route = get_route_from_app(app, self._endpoint) - uri_path = self._get_uri_path(app, values, route) + uri_path = self._get_uri_path( + templated=self._templated, + endpoint=self._endpoint, + app=app, + values=values, + params=self._param_values, + route=route, + ) return UrlForType(hypermedia=uri_path) diff --git a/pyproject.toml b/pyproject.toml index 394485c..f1f1303 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -225,7 +225,7 @@ check-str-concat-over-line-jumps = true ignore-comments = true ignore-docstrings = true ignore-signatures = true -min-similarity-lines = 10 +min-similarity-lines = 12 [tool.pylint.variables] allow-global-unused-variables = false From cd3dc9b3eb763cce51baf7a03680631e46c2a3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 19:30:14 -0300 Subject: [PATCH 49/66] Use future annotations in HAL for Python 3.8 compatibility --- fastapi_hypermodel/hal/hal_hypermodel.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index 4535b49..ff78843 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import ( Any, Callable, From d0857c42d6855af0fd204dac5d2023be02fa0760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 19:42:16 -0300 Subject: [PATCH 50/66] Remove generic type annotation in inheritance --- fastapi_hypermodel/hal/hal_hypermodel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index ff78843..4d25aa1 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -132,7 +132,7 @@ def __call__( HALLinkType = Union[HALFor, Sequence[HALFor]] -class FrozenDict(frozendict[str, HALLinkType]): +class FrozenDict(frozendict): # type: ignore @classmethod def __get_pydantic_core_schema__( cls: Type[Self], From 076cff20065b258c671d1edc0635fd5ad7c0b7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 19:45:43 -0300 Subject: [PATCH 51/66] Add Python 3.12 to CICD --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f8fccdd..eb551b2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.8", "3.9", "3.10", "3.11"] + python: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v1 From 4353e7f60f0c53deedd798d3ae412e0d438ce8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 21:15:20 -0300 Subject: [PATCH 52/66] Make templated Optional --- fastapi_hypermodel/base/hypermodel.py | 2 +- fastapi_hypermodel/hal/hal_hypermodel.py | 4 ++-- fastapi_hypermodel/siren/siren_action.py | 7 ++++--- fastapi_hypermodel/siren/siren_link.py | 5 +++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/fastapi_hypermodel/base/hypermodel.py b/fastapi_hypermodel/base/hypermodel.py index b7d3ced..200b6c3 100644 --- a/fastapi_hypermodel/base/hypermodel.py +++ b/fastapi_hypermodel/base/hypermodel.py @@ -48,7 +48,7 @@ def __call__( @staticmethod def _get_uri_path( *, - templated: bool, + templated: Optional[bool], app: Starlette, values: Mapping[str, Any], route: Union[Route, str], diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index 4d25aa1..c7df9f5 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -59,7 +59,7 @@ class HALFor(HALForType, AbstractHyperField[HALForType]): _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() - _templated: bool = PrivateAttr() + _templated: Optional[bool] = PrivateAttr() # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal _title: Optional[str] = PrivateAttr() _name: Optional[str] = PrivateAttr() @@ -74,7 +74,7 @@ def __init__( param_values: Optional[Mapping[str, str]] = None, description: Optional[str] = None, condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, - templated: bool = False, + templated: Optional[bool] = None, title: Optional[str] = None, name: Optional[str] = None, type_: Optional[str] = None, diff --git a/fastapi_hypermodel/siren/siren_action.py b/fastapi_hypermodel/siren/siren_action.py index 1cd1267..c6a8b82 100644 --- a/fastapi_hypermodel/siren/siren_action.py +++ b/fastapi_hypermodel/siren/siren_action.py @@ -7,6 +7,7 @@ Dict, List, Mapping, + Optional, Sequence, Type, Union, @@ -42,7 +43,7 @@ class SirenActionType(SirenBase): href: UrlType = Field(default=UrlType()) type_: Union[str, None] = Field(default=None, alias="type") fields: Union[Sequence[SirenFieldType], None] = Field(default=None) - templated: bool = Field(default=False) + templated: Optional[bool] = Field(default=None) model_config = ConfigDict( populate_by_name=True, @@ -60,7 +61,7 @@ def mandatory(cls: Type[Self], value: Union[str, None]) -> str: class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): # pylint: disable=too-many-instance-attributes _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() - _templated: bool = PrivateAttr() + _templated: Optional[bool] = PrivateAttr() _condition: Union[Callable[[Mapping[str, Any]], bool], None] = PrivateAttr() _populate_fields: bool = PrivateAttr() @@ -76,7 +77,7 @@ def __init__( self: Self, endpoint: Union[HasName, str], param_values: Union[Mapping[str, str], None] = None, - templated: bool = False, + templated: Optional[bool] = None, condition: Union[Callable[[Mapping[str, Any]], bool], None] = None, populate_fields: bool = True, title: Union[str, None] = None, diff --git a/fastapi_hypermodel/siren/siren_link.py b/fastapi_hypermodel/siren/siren_link.py index f4d6ea4..f414c2b 100644 --- a/fastapi_hypermodel/siren/siren_link.py +++ b/fastapi_hypermodel/siren/siren_link.py @@ -4,6 +4,7 @@ Any, Callable, Mapping, + Optional, Sequence, Type, Union, @@ -48,7 +49,7 @@ class SirenLinkFor(SirenLinkType, AbstractHyperField[SirenLinkType]): # pylint: disable=too-many-instance-attributes _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() - _templated: bool = PrivateAttr() + _templated: Optional[bool] = PrivateAttr() _condition: Union[Callable[[Mapping[str, Any]], bool], None] = PrivateAttr() # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal @@ -61,7 +62,7 @@ def __init__( self: Self, endpoint: Union[HasName, str], param_values: Union[Mapping[str, str], None] = None, - templated: bool = False, + templated: Optional[bool] = None, condition: Union[Callable[[Mapping[str, Any]], bool], None] = None, title: Union[str, None] = None, type_: Union[str, None] = None, From 3a4092014fd748b26b0081b125a236b3a5a2cacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 21:15:49 -0300 Subject: [PATCH 53/66] Exclude unset instead of modifying serializers --- examples/hal/app.py | 51 +++++++++++++++++--- examples/siren/app.py | 49 ++++++++++++++++--- fastapi_hypermodel/siren/siren_base.py | 8 --- fastapi_hypermodel/siren/siren_hypermodel.py | 5 -- 4 files changed, 85 insertions(+), 28 deletions(-) diff --git a/examples/hal/app.py b/examples/hal/app.py index 9307304..c2c9947 100644 --- a/examples/hal/app.py +++ b/examples/hal/app.py @@ -11,9 +11,9 @@ FrozenDict, HALFor, HALHyperModel, + HALLinks, HALResponse, ) -from fastapi_hypermodel.hal.hal_hypermodel import HALLinks class ItemSummary(HALHyperModel): @@ -96,17 +96,32 @@ class PersonUpdate(BaseModel): HALHyperModel.register_curies(curies) -@app.get("/items", response_model=ItemCollection, response_class=HALResponse) +@app.get( + "/items", + response_model=ItemCollection, + response_model_exclude_unset=True, + response_class=HALResponse, +) def read_items() -> Any: return items -@app.get("/items/{id_}", response_model=Item, response_class=HALResponse) +@app.get( + "/items/{id_}", + response_model=Item, + response_model_exclude_unset=True, + response_class=HALResponse, +) def read_item(id_: str) -> Any: return next(item for item in items["sc:items"] if item["id_"] == id_) -@app.put("/items/{id_}", response_model=Item, response_class=HALResponse) +@app.put( + "/items/{id_}", + response_model=Item, + response_model_exclude_unset=True, + response_class=HALResponse, +) def update_item(id_: str, item: ItemUpdate) -> Any: base_item = next(item for item in items["sc:items"] if item["id_"] == id_) update_item = cast(ItemData, item.model_dump(exclude_none=True)) @@ -114,17 +129,32 @@ def update_item(id_: str, item: ItemUpdate) -> Any: return base_item -@app.get("/people", response_model=PersonCollection, response_class=HALResponse) +@app.get( + "/people", + response_model=PersonCollection, + response_model_exclude_unset=True, + response_class=HALResponse, +) def read_people() -> Any: return people -@app.get("/people/{id_}", response_model=Person, response_class=HALResponse) +@app.get( + "/people/{id_}", + response_model=Person, + response_model_exclude_unset=True, + response_class=HALResponse, +) def read_person(id_: str) -> Any: return next(person for person in people["people"] if person["id_"] == id_) -@app.put("/people/{id_}", response_model=Person, response_class=HALResponse) +@app.put( + "/people/{id_}", + response_model=Person, + response_model_exclude_unset=True, + response_class=HALResponse, +) def update_person(id_: str, person: PersonUpdate) -> Any: base_person = next(person for person in people["people"] if person["id_"] == id_) update_person = cast(PersonData, person.model_dump(exclude_none=True)) @@ -132,7 +162,12 @@ def update_person(id_: str, person: PersonUpdate) -> Any: return base_person -@app.put("/people/{id_}/items", response_model=Person, response_class=HALResponse) +@app.put( + "/people/{id_}/items", + response_model=Person, + response_model_exclude_unset=True, + response_class=HALResponse, +) def put_person_items(id_: str, item: ItemCreate) -> Any: complete_item = next( (item_ for item_ in items["sc:items"] if item_["id_"] == item.id_), diff --git a/examples/siren/app.py b/examples/siren/app.py index f55d12e..a854a4c 100644 --- a/examples/siren/app.py +++ b/examples/siren/app.py @@ -107,17 +107,32 @@ class PersonUpdate(BaseModel): SirenHyperModel.init_app(app) -@app.get("/items", response_model=ItemCollection, response_class=SirenResponse) +@app.get( + "/items", + response_model=ItemCollection, + response_model_exclude_unset=True, + response_class=SirenResponse, +) def read_items() -> Any: return items -@app.get("/items/{id_}", response_model=Item, response_class=SirenResponse) +@app.get( + "/items/{id_}", + response_model=Item, + response_model_exclude_unset=True, + response_class=SirenResponse, +) def read_item(id_: str) -> Any: return next(item for item in items["items"] if item["id_"] == id_) -@app.put("/items/{id_}", response_model=Item, response_class=SirenResponse) +@app.put( + "/items/{id_}", + response_model=Item, + response_model_exclude_unset=True, + response_class=SirenResponse, +) def update_item(id_: str, item: ItemUpdate) -> Any: base_item = next(item for item in items["items"] if item["id_"] == id_) update_item = cast(ItemData, item.model_dump(exclude_none=True)) @@ -125,17 +140,32 @@ def update_item(id_: str, item: ItemUpdate) -> Any: return base_item -@app.get("/people", response_model=PersonCollection, response_class=SirenResponse) +@app.get( + "/people", + response_model=PersonCollection, + response_model_exclude_unset=True, + response_class=SirenResponse, +) def read_people() -> Any: return people -@app.get("/people/{id_}", response_model=Person, response_class=SirenResponse) +@app.get( + "/people/{id_}", + response_model=Person, + response_model_exclude_unset=True, + response_class=SirenResponse, +) def read_person(id_: str) -> Any: return next(person for person in people["people"] if person["id_"] == id_) -@app.put("/people/{id_}", response_model=Person, response_class=SirenResponse) +@app.put( + "/people/{id_}", + response_model=Person, + response_model_exclude_unset=True, + response_class=SirenResponse, +) def update_person(id_: str, person: PersonUpdate) -> Any: base_person = next(person for person in people["people"] if person["id_"] == id_) update_person = cast(PersonData, person.model_dump(exclude_none=True)) @@ -143,7 +173,12 @@ def update_person(id_: str, person: PersonUpdate) -> Any: return base_person -@app.put("/people/{id_}/items", response_model=Person, response_class=SirenResponse) +@app.put( + "/people/{id_}/items", + response_model=Person, + response_model_exclude_unset=True, + response_class=SirenResponse, +) def put_person_items(id_: str, item: ItemCreate) -> Any: complete_item = next( (item_ for item_ in items["items"] if item_["id_"] == item.id_), diff --git a/fastapi_hypermodel/siren/siren_base.py b/fastapi_hypermodel/siren/siren_base.py index 6c9ed6f..dc2d393 100644 --- a/fastapi_hypermodel/siren/siren_base.py +++ b/fastapi_hypermodel/siren/siren_base.py @@ -1,8 +1,6 @@ from __future__ import annotations from typing import ( - Any, - Mapping, Sequence, Union, ) @@ -11,9 +9,7 @@ BaseModel, ConfigDict, Field, - model_serializer, ) -from typing_extensions import Self class SirenBase(BaseModel): @@ -21,7 +17,3 @@ class SirenBase(BaseModel): title: Union[str, None] = Field(default=None) model_config = ConfigDict(populate_by_name=True) - - @model_serializer - def serialize(self: Self) -> Mapping[str, Any]: - return {self.model_fields[k].alias or k: v for k, v in self if v} diff --git a/fastapi_hypermodel/siren/siren_hypermodel.py b/fastapi_hypermodel/siren/siren_hypermodel.py index 4412b6f..84d3604 100644 --- a/fastapi_hypermodel/siren/siren_hypermodel.py +++ b/fastapi_hypermodel/siren/siren_hypermodel.py @@ -13,7 +13,6 @@ from pydantic import ( ConfigDict, Field, - model_serializer, model_validator, ) from typing_extensions import Self @@ -177,10 +176,6 @@ def no_action_outside_of_actions(self: Self) -> Self: return self - @model_serializer - def serialize(self: Self) -> Mapping[str, Any]: - return {self.model_fields[k].alias or k: v for k, v in self if v} - @staticmethod def as_embedded(field: SirenHyperModel, rel: str) -> SirenEmbeddedType: return SirenEmbeddedType(rel=[rel], **field.model_dump()) From 3bdaada4662401bfd14f637f04e8be77ebdaa156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 21:24:50 -0300 Subject: [PATCH 54/66] Use Optional instead of Unions --- fastapi_hypermodel/hal/hal_hypermodel.py | 4 +-- fastapi_hypermodel/hal/hal_response.py | 2 +- fastapi_hypermodel/siren/siren_action.py | 36 ++++++++++---------- fastapi_hypermodel/siren/siren_base.py | 6 ++-- fastapi_hypermodel/siren/siren_field.py | 8 ++--- fastapi_hypermodel/siren/siren_hypermodel.py | 9 ++--- fastapi_hypermodel/siren/siren_link.py | 28 +++++++-------- fastapi_hypermodel/siren/siren_response.py | 6 ++-- 8 files changed, 50 insertions(+), 49 deletions(-) diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index c7df9f5..6c3006f 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -41,7 +41,7 @@ class HALForType(BaseModel): templated: Optional[bool] = None title: Optional[str] = None name: Optional[str] = None - type_: Union[str, None] = Field(default=None, alias="type") + type_: Optional[str] = Field(default=None, alias="type") hreflang: Optional[str] = None profile: Optional[str] = None deprecation: Optional[str] = None @@ -157,7 +157,7 @@ def __get_pydantic_core_schema__( ) -HALLinks = Annotated[Union[FrozenDict, None], Field(alias="_links")] +HALLinks = Annotated[Optional[FrozenDict], Field(alias="_links")] class HALHyperModel(HyperModel): diff --git a/fastapi_hypermodel/hal/hal_response.py b/fastapi_hypermodel/hal/hal_response.py index ddda1d9..34632df 100644 --- a/fastapi_hypermodel/hal/hal_response.py +++ b/fastapi_hypermodel/hal/hal_response.py @@ -162,7 +162,7 @@ def render(self: Self, content: Any) -> bytes: return super().render(content) -def get_hal_link(response: Any, link_name: str) -> Union[HALForType, None]: +def get_hal_link(response: Any, link_name: str) -> Optional[HALForType]: links = response.get("_links", {}) link = links.get(link_name, {}) return HALForType.model_validate(link) if link else None diff --git a/fastapi_hypermodel/siren/siren_action.py b/fastapi_hypermodel/siren/siren_action.py index c6a8b82..5845d5b 100644 --- a/fastapi_hypermodel/siren/siren_action.py +++ b/fastapi_hypermodel/siren/siren_action.py @@ -41,8 +41,8 @@ class SirenActionType(SirenBase): name: str = Field(default="") method: str = Field(default="GET") href: UrlType = Field(default=UrlType()) - type_: Union[str, None] = Field(default=None, alias="type") - fields: Union[Sequence[SirenFieldType], None] = Field(default=None) + type_: Optional[str] = Field(default=None, alias="type") + fields: Optional[Sequence[SirenFieldType]] = Field(default=None) templated: Optional[bool] = Field(default=None) model_config = ConfigDict( @@ -51,7 +51,7 @@ class SirenActionType(SirenBase): @field_validator("name", "href") @classmethod - def mandatory(cls: Type[Self], value: Union[str, None]) -> str: + def mandatory(cls: Type[Self], value: Optional[str]) -> str: if not value: error_message = f"Field name and href are mandatory, {value}" raise ValueError(error_message) @@ -62,29 +62,29 @@ class SirenActionFor(SirenActionType, AbstractHyperField[SirenActionType]): # p _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _templated: Optional[bool] = PrivateAttr() - _condition: Union[Callable[[Mapping[str, Any]], bool], None] = PrivateAttr() + _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() _populate_fields: bool = PrivateAttr() # For details on the folllowing fields, check https://github.com/kevinswiber/siren - _class: Union[Sequence[str], None] = PrivateAttr() - _title: Union[str, None] = PrivateAttr() + _class: Optional[Sequence[str]] = PrivateAttr() + _title: Optional[str] = PrivateAttr() _name: str = PrivateAttr() - _method: Union[str, None] = PrivateAttr() - _type: Union[str, None] = PrivateAttr() - _fields: Union[Sequence[SirenFieldType], None] = PrivateAttr() + _method: Optional[str] = PrivateAttr() + _type: Optional[str] = PrivateAttr() + _fields: Optional[Sequence[SirenFieldType]] = PrivateAttr() def __init__( self: Self, endpoint: Union[HasName, str], - param_values: Union[Mapping[str, str], None] = None, + param_values: Optional[Mapping[str, str]] = None, templated: Optional[bool] = None, - condition: Union[Callable[[Mapping[str, Any]], bool], None] = None, + condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, populate_fields: bool = True, - title: Union[str, None] = None, - type_: Union[str, None] = None, - class_: Union[Sequence[str], None] = None, - fields: Union[Sequence[SirenFieldType], None] = None, - method: Union[str, None] = None, + title: Optional[str] = None, + type_: Optional[str] = None, + class_: Optional[Sequence[str]] = None, + fields: Optional[Sequence[SirenFieldType]] = None, + method: Optional[str] = None, name: str = "", **kwargs: Any, ) -> None: @@ -133,8 +133,8 @@ def _compute_fields( return self._prepopulate_fields(fields, values) def __call__( - self: Self, app: Union[Starlette, None], values: Mapping[str, Any] - ) -> Union[SirenActionType, None]: + self: Self, app: Optional[Starlette], values: Mapping[str, Any] + ) -> Optional[SirenActionType]: if app is None: return None diff --git a/fastapi_hypermodel/siren/siren_base.py b/fastapi_hypermodel/siren/siren_base.py index dc2d393..636891f 100644 --- a/fastapi_hypermodel/siren/siren_base.py +++ b/fastapi_hypermodel/siren/siren_base.py @@ -1,8 +1,8 @@ from __future__ import annotations from typing import ( + Optional, Sequence, - Union, ) from pydantic import ( @@ -13,7 +13,7 @@ class SirenBase(BaseModel): - class_: Union[Sequence[str], None] = Field(default=None, alias="class") - title: Union[str, None] = Field(default=None) + class_: Optional[Sequence[str]] = Field(default=None, alias="class") + title: Optional[str] = Field(default=None) model_config = ConfigDict(populate_by_name=True) diff --git a/fastapi_hypermodel/siren/siren_field.py b/fastapi_hypermodel/siren/siren_field.py index 2db4f6e..dfc996e 100644 --- a/fastapi_hypermodel/siren/siren_field.py +++ b/fastapi_hypermodel/siren/siren_field.py @@ -2,8 +2,8 @@ from typing import ( Any, + Optional, Type, - Union, ) from pydantic import ( @@ -18,8 +18,8 @@ class SirenFieldType(SirenBase): name: str - type_: Union[str, None] = Field(default=None, alias="type") - value: Union[Any, None] = None + type_: Optional[str] = Field(default=None, alias="type") + value: Optional[Any] = None model_config = ConfigDict(populate_by_name=True) @@ -32,7 +32,7 @@ def from_field_info(cls: Type[Self], name: str, field_info: FieldInfo) -> Self: ) @staticmethod - def parse_type(python_type: Union[Type[Any], None]) -> str: + def parse_type(python_type: Optional[Type[Any]]) -> str: type_repr = repr(python_type) text_types = ("str",) diff --git a/fastapi_hypermodel/siren/siren_hypermodel.py b/fastapi_hypermodel/siren/siren_hypermodel.py index 84d3604..bb76ec2 100644 --- a/fastapi_hypermodel/siren/siren_hypermodel.py +++ b/fastapi_hypermodel/siren/siren_hypermodel.py @@ -5,6 +5,7 @@ Dict, List, Mapping, + Optional, Sequence, Union, cast, @@ -25,10 +26,10 @@ class SirenEntityType(SirenBase): - properties: Union[Mapping[str, Any], None] = None - entities: Union[Sequence[Union[SirenEmbeddedType, SirenLinkType]], None] = None - links: Union[Sequence[SirenLinkType], None] = None - actions: Union[Sequence[SirenActionType], None] = None + properties: Optional[Mapping[str, Any]] = None + entities: Optional[Sequence[Union[SirenEmbeddedType, SirenLinkType]]] = None + links: Optional[Sequence[SirenLinkType]] = None + actions: Optional[Sequence[SirenActionType]] = None class SirenEmbeddedType(SirenEntityType): diff --git a/fastapi_hypermodel/siren/siren_link.py b/fastapi_hypermodel/siren/siren_link.py index f414c2b..e69e871 100644 --- a/fastapi_hypermodel/siren/siren_link.py +++ b/fastapi_hypermodel/siren/siren_link.py @@ -32,13 +32,13 @@ class SirenLinkType(SirenBase): rel: Sequence[str] = Field(default_factory=list) href: UrlType = Field(default=UrlType()) - type_: Union[str, None] = Field(default=None, alias="type") + type_: Optional[str] = Field(default=None, alias="type") model_config = ConfigDict(populate_by_name=True) @field_validator("rel", "href") @classmethod - def mandatory(cls: Type[Self], value: Union[str, None]) -> str: + def mandatory(cls: Type[Self], value: Optional[str]) -> str: if not value: error_message = "Field rel and href are mandatory" raise ValueError(error_message) @@ -50,24 +50,24 @@ class SirenLinkFor(SirenLinkType, AbstractHyperField[SirenLinkType]): _endpoint: str = PrivateAttr() _param_values: Mapping[str, str] = PrivateAttr() _templated: Optional[bool] = PrivateAttr() - _condition: Union[Callable[[Mapping[str, Any]], bool], None] = PrivateAttr() + _condition: Optional[Callable[[Mapping[str, Any]], bool]] = PrivateAttr() # For details on the folllowing fields, check https://datatracker.ietf.org/doc/html/draft-kelly-json-hal - _title: Union[str, None] = PrivateAttr() - _type: Union[str, None] = PrivateAttr() + _title: Optional[str] = PrivateAttr() + _type: Optional[str] = PrivateAttr() _rel: Sequence[str] = PrivateAttr() - _class: Union[Sequence[str], None] = PrivateAttr() + _class: Optional[Sequence[str]] = PrivateAttr() def __init__( self: Self, endpoint: Union[HasName, str], - param_values: Union[Mapping[str, str], None] = None, + param_values: Optional[Mapping[str, str]] = None, templated: Optional[bool] = None, - condition: Union[Callable[[Mapping[str, Any]], bool], None] = None, - title: Union[str, None] = None, - type_: Union[str, None] = None, - rel: Union[Sequence[str], None] = None, - class_: Union[Sequence[str], None] = None, + condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, + title: Optional[str] = None, + type_: Optional[str] = None, + rel: Optional[Sequence[str]] = None, + class_: Optional[Sequence[str]] = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -83,8 +83,8 @@ def __init__( self._class = class_ def __call__( - self: Self, app: Union[Starlette, None], values: Mapping[str, Any] - ) -> Union[SirenLinkType, None]: + self: Self, app: Optional[Starlette], values: Mapping[str, Any] + ) -> Optional[SirenLinkType]: if app is None: return None diff --git a/fastapi_hypermodel/siren/siren_response.py b/fastapi_hypermodel/siren/siren_response.py index ea6cd9b..fdbc917 100644 --- a/fastapi_hypermodel/siren/siren_response.py +++ b/fastapi_hypermodel/siren/siren_response.py @@ -2,7 +2,7 @@ from typing import ( Any, - Union, + Optional, ) import jsonschema @@ -26,13 +26,13 @@ def render(self: Self, content: Any) -> bytes: return super().render(content) -def get_siren_link(response: Any, link_name: str) -> Union[SirenLinkType, None]: +def get_siren_link(response: Any, link_name: str) -> Optional[SirenLinkType]: links = response.get("links", []) link = next((link for link in links if link_name in link.get("rel")), None) return SirenLinkType.model_validate(link) if link else None -def get_siren_action(response: Any, action_name: str) -> Union[SirenActionType, None]: +def get_siren_action(response: Any, action_name: str) -> Optional[SirenActionType]: actions = response.get("actions", []) action = next( (action for action in actions if action_name in action.get("name")), None From ddcf94fd1f721198128c26c9c6241623cf9d42d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sun, 28 Jan 2024 21:27:36 -0300 Subject: [PATCH 55/66] Remove fields based on jsonschema validations --- fastapi_hypermodel/siren/siren_base.py | 14 +++++++++++--- fastapi_hypermodel/siren/siren_hypermodel.py | 14 +++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/fastapi_hypermodel/siren/siren_base.py b/fastapi_hypermodel/siren/siren_base.py index 636891f..6c9ed6f 100644 --- a/fastapi_hypermodel/siren/siren_base.py +++ b/fastapi_hypermodel/siren/siren_base.py @@ -1,19 +1,27 @@ from __future__ import annotations from typing import ( - Optional, + Any, + Mapping, Sequence, + Union, ) from pydantic import ( BaseModel, ConfigDict, Field, + model_serializer, ) +from typing_extensions import Self class SirenBase(BaseModel): - class_: Optional[Sequence[str]] = Field(default=None, alias="class") - title: Optional[str] = Field(default=None) + class_: Union[Sequence[str], None] = Field(default=None, alias="class") + title: Union[str, None] = Field(default=None) model_config = ConfigDict(populate_by_name=True) + + @model_serializer + def serialize(self: Self) -> Mapping[str, Any]: + return {self.model_fields[k].alias or k: v for k, v in self if v} diff --git a/fastapi_hypermodel/siren/siren_hypermodel.py b/fastapi_hypermodel/siren/siren_hypermodel.py index bb76ec2..4412b6f 100644 --- a/fastapi_hypermodel/siren/siren_hypermodel.py +++ b/fastapi_hypermodel/siren/siren_hypermodel.py @@ -5,7 +5,6 @@ Dict, List, Mapping, - Optional, Sequence, Union, cast, @@ -14,6 +13,7 @@ from pydantic import ( ConfigDict, Field, + model_serializer, model_validator, ) from typing_extensions import Self @@ -26,10 +26,10 @@ class SirenEntityType(SirenBase): - properties: Optional[Mapping[str, Any]] = None - entities: Optional[Sequence[Union[SirenEmbeddedType, SirenLinkType]]] = None - links: Optional[Sequence[SirenLinkType]] = None - actions: Optional[Sequence[SirenActionType]] = None + properties: Union[Mapping[str, Any], None] = None + entities: Union[Sequence[Union[SirenEmbeddedType, SirenLinkType]], None] = None + links: Union[Sequence[SirenLinkType], None] = None + actions: Union[Sequence[SirenActionType], None] = None class SirenEmbeddedType(SirenEntityType): @@ -177,6 +177,10 @@ def no_action_outside_of_actions(self: Self) -> Self: return self + @model_serializer + def serialize(self: Self) -> Mapping[str, Any]: + return {self.model_fields[k].alias or k: v for k, v in self if v} + @staticmethod def as_embedded(field: SirenHyperModel, rel: str) -> SirenEmbeddedType: return SirenEmbeddedType(rel=[rel], **field.model_dump()) From a01ec21c9f567b3206d2eeebb59cedc2e88821e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Wed, 31 Jan 2024 23:07:12 -0300 Subject: [PATCH 56/66] Improve basics documentation with code tabs --- docs/basics.md | 415 +++++++++++++++++++++++++++++++++++++++++++------ mkdocs.yml | 5 + 2 files changed, 374 insertions(+), 46 deletions(-) diff --git a/docs/basics.md b/docs/basics.md index 1a63241..a55a48f 100644 --- a/docs/basics.md +++ b/docs/basics.md @@ -1,70 +1,356 @@ #  Basic Usage -## Import `HyperModel` and optionally `HyperRef` +## Choose Hypermedia Formats -```python -from fastapi import FastAPI +Fastapi-hypermodel has support for following [Hypermedia Maturity Model +Levels](https://8thlight.com/insights/the-hypermedia-maturity-model): -from fastapi_hypermodel import HyperModel, UrlFor, LinkSet -``` +- Level 0: URLFor - Plain Text +- Level 1: [Hypertext Application Language (HAL)](https://datatracker.ietf.org/doc/html/draft-kelly-json-hal) +- Level 2: [Siren](https://github.com/kevinswiber/siren) + +There is a fully working example for each format in the [examples](../examples) +directory. + +## Initialization + +=== "URLFor" -`HyperModel` will be your model base-class. + ```python + from fastapi import FastAPI + + from fastapi_hypermodel import HyperModel, UrlFor + ``` + +=== "HAL" + + ```python + from fastapi import FastAPI + + from fastapi_hypermodel import ( + FrozenDict, + HALFor, + HALHyperModel, + HALLinks, + HALResponse, + ) + ``` + +=== "Siren" + + ```python + from fastapi import FastAPI + + from fastapi_hypermodel import ( + SirenActionFor, + SirenHyperModel, + SirenLinkFor, + SirenResponse, + ) + ``` ## Create your basic models -We'll create two models, a brief item summary including ID, name, and a link, and a full model containing additional information. We'll use `ItemSummary` in our item list, and `ItemDetail` for full item information. +We'll create two models, a brief item summary including ID, name, and a link, +and a full model containing additional information. We'll use `ItemSummary` in +our item list, and `ItemDetail` for full item information. -```python -class ItemSummary(HyperModel): - id: str - name: str -class ItemDetail(ItemSummary): - description: Optional[str] = None - price: float +=== "URLFor" + + ```python + class ItemSummary(HyperModel): + name: str + id_: str + + href: UrlFor = UrlFor("read_item", {"id_": ""}) + update: UrlFor = UrlFor("update_item", {"id_": ""}) + + class Item(ItemSummary): + description: Optional[str] = None + price: float + + class ItemCollection(HyperModel): + items: Sequence[Item] + + href: UrlFor = UrlFor("read_items") + find: UrlFor = UrlFor("read_item", templated=True) + update: UrlFor = UrlFor("update_item", templated=True) + ``` + +=== "HAL" + + ```python + class ItemSummary(HALHyperModel): + name: str + id_: str + + links: HALLinks = FrozenDict({ + "self": HALFor("read_item", {"id_": ""}), + "update": HALFor("update_item", {"id_": ""}), + }) + + class Item(ItemSummary): + description: Optional[str] = None + price: float + + class ItemCollection(HALHyperModel): + items: Sequence[Item] = Field(alias="sc:items") + + links: HALLinks = FrozenDict({ + "self": HALFor("read_items"), + "find": HALFor("read_item", templated=True), + "update": HALFor("update_item", templated=True), + }) + ``` + +=== "Siren" + + ```python + class ItemSummary(SirenHyperModel): + name: str + id_: str + + links: Sequence[SirenLinkFor] = ( + SirenLinkFor("read_item", {"id_": ""}, rel=["self"]), + ) + + actions: Sequence[SirenActionFor] = ( + SirenActionFor("update_item", {"id_": ""}, name="update"), + ) + + + class Item(ItemSummary): + description: Optional[str] = None + price: float + + class ItemCollection(SirenHyperModel): + items: Sequence[Item] + + links: Sequence[SirenLinkFor] = (SirenLinkFor("read_items", rel=["self"]),) + + actions: Sequence[SirenActionFor] = ( + SirenActionFor("read_item", templated=True, name="find"), + SirenActionFor("update_item", templated=True, name="update"), + ) + ``` + +## Define your data + +=== "URLFor" + + ```python + from typing import List + + from typing_extensions import NotRequired, TypedDict + + + class Item(TypedDict): + id_: str + name: str + price: float + description: NotRequired[str] + + + class Items(TypedDict): + items: List[Item] + + + items: Items = { + "items": [ + {"id_": "item01", "name": "Foo", "price": 50.2}, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62, + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5, + }, + ] + } + ``` + +=== "HAL" + + ```python + from typing import List + + from typing_extensions import NotRequired, TypedDict + + from fastapi_hypermodel import HALForType, UrlType + + + class Item(TypedDict): + id_: str + name: str + price: float + description: NotRequired[str] + + + Items = TypedDict("Items", {"sc:items": List[Item]}) + + items: Items = { + "sc:items": [ + {"id_": "item01", "name": "Foo", "price": 10.2}, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62, + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5, + }, + ] + } + + curies: List[HALForType] = [ + HALForType( + href=UrlType("https://schema.org/{rel}"), + name="sc", + templated=True, + ) + ] + ``` + +=== "Siren" + + ```python + from typing import List + + from typing_extensions import NotRequired, TypedDict + + + class Item(TypedDict): + id_: str + name: str + price: float + description: NotRequired[str] + + + class Items(TypedDict): + items: List[Item] + + + items: Items = { + "items": [ + {"id_": "item01", "name": "Foo", "price": 10.2}, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62, + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5, + }, + ] + } + ``` -class Person(HyperModel): - name: str - id: str - items: List[ItemSummary] -``` ## Create and attach your app We'll now create our FastAPI app, and bind it to our `HyperModel` base class. -```python -from fastapi import FastAPI +=== "URLFor" -app = FastAPI() -HyperModel.init_app(app) -``` + ```python + app = FastAPI() + HyperModel.init_app(app) + ``` + +=== "HAL" + + ```python + app = FastAPI() + HALHyperModel.init_app(app) + HALHyperModel.register_curies(curies) + ``` + +=== "Siren" + + ```python + app = FastAPI() + SirenHyperModel.init_app(app) + ``` ## Add some API views -We'll create an API view for a list of items, as well as details about an individual item. Note that we pass the item ID with our `{item_id}` URL variable. +We'll create an API view for a list of items, as well as details about an +individual item. Note that we pass the item ID with our `{item_id}` URL +variable. -```python -@app.get("/items", response_model=List[ItemSummary]) -def read_items(): - return list(items.values()) +=== "URLFor" -@app.get("/items/{item_id}", response_model=ItemDetail) -def read_item(item_id: str): - return items[item_id] + ```python + @app.get("/items", response_model=ItemCollection) + def read_items() -> Any: + return items -@app.get("/people/{person_id}", response_model=Person) -def read_person(person_id: str): - return people[person_id] + @app.get("/items/{id_}", response_model=Item) + def read_item(id_: str) -> Any: + return next(item for item in items["items"] if item["id_"] == id_) + ``` -@app.get("/people/{person_id}/items", response_model=List[ItemDetail]) -def read_person_items(person_id: str): - return people[person_id]["items"] -``` +=== "HAL" + + ```python + @app.get("/items", response_model=ItemCollection, response_class=HALResponse) + def read_items() -> Any: + return items + + @app.get("/items/{id_}", response_model=Item, response_class=HALResponse) + def read_item(id_: str) -> Any: + return next(item for item in items["sc:items"] if item["id_"] == id_) + ``` + +=== "Siren" + + ```python + @app.get("/items", response_model=ItemCollection, response_class=SirenResponse) + def read_items() -> Any: + return items + + @app.get("/items/{id_}", response_model=Item, response_class=SirenResponse) + def read_item(id_: str) -> Any: + return next(item for item in items["items"] if item["id_"] == id_) + ``` ## Create a model `href` -We'll now go back and add an `href` field with a special `UrlFor` value. This `UrlFor` class defines how our href elements will be generated. We'll change our `ItemSummary` class to: +We'll now go back and add an `href` field with a special `UrlFor` value. This +`UrlFor` class defines how our href elements will be generated. We'll change our +`ItemSummary` class to: ```python class ItemSummary(HyperModel): @@ -77,17 +363,29 @@ The `UrlFor` class takes two arguments: ### `endpoint` -Name of your FastAPI endpoint function you want to link to. In our example, we want our item summary to link to the corresponding item detail page, which maps to our `read_item` function. +Name of your FastAPI endpoint function you want to link to. In our example, we +want our item summary to link to the corresponding item detail page, which maps +to our `read_item` function. -Alternatively, rather than providing the endpoint name, you can provide a reference to the endpoint function itself, for example `UrlFor(read_item, {"item_id": ""})`. This can help with larger projects where function names may be refactored. +Alternatively, rather than providing the endpoint name, you can provide a +reference to the endpoint function itself, for example `UrlFor(read_item, +{"item_id": ""})`. This can help with larger projects where function names +may be refactored. ### `values` (optional depending on endpoint) -Same keyword arguments as FastAPI's url_path_for, except string arguments enclosed in < > will be interpreted as attributes to pull from the object. For example, here we need to pass an `item_id` argument as required by our endpoint function, and we want to populate that with our item object's `id` attribute. +Same keyword arguments as FastAPI's url_path_for, except string arguments +enclosed in < > will be interpreted as attributes to pull from the object. For +example, here we need to pass an `item_id` argument as required by our endpoint +function, and we want to populate that with our item object's `id` attribute. ## Create a link set -In some cases we want to create a map of relational links. In these cases we can create a `LinkSet` field describing each link and it's relationship to the object. The `LinkSet` class is really just a spicy dictionary that tells the parent `HyperModel` to "render" each link in the link set, and includes some extra OpenAPI schema stuff. +In some cases we want to create a map of relational links. In these cases we can +create a `LinkSet` field describing each link and it's relationship to the +object. The `LinkSet` class is really just a spicy dictionary that tells the +parent `HyperModel` to "render" each link in the link set, and includes some +extra OpenAPI schema stuff. ```python class Person(HyperModel): @@ -106,9 +404,13 @@ class Person(HyperModel): ## Putting it all together -For this example, we can make a dictionary containing some fake data, and add extra models, even nesting models if we want. A complete example based on this documentation can be found [here](https://github.com/jtc42/fastapi-hypermodel/blob/main/examples/simple_app.py). +For this example, we can make a dictionary containing some fake data, and add +extra models, even nesting models if we want. A complete example based on this +documentation can be found +[here](https://github.com/jtc42/fastapi-hypermodel/blob/main/examples/simple_app.py). -If we run the example application and go to our `/items` URL, we should get a response like: +If we run the example application and go to our `/items` URL, we should get a +response like: ```json [ @@ -129,3 +431,24 @@ If we run the example application and go to our `/items` URL, we should get a re } ] ``` + + + + +=== "URLFor" + + ```python + + ``` + +=== "HAL" + + ```python + + ``` + +=== "Siren" + + ```python + + ``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 9e1b3b4..4056db5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,8 @@ site_name: FastAPI HyperModel theme: name: material + features: + - content.code.copy repo_name: jtc42/fastapi-hypermodel repo_url: https://github.com/jtc42/fastapi-hypermodel @@ -12,6 +14,9 @@ markdown_extensions: - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + combine_header_slug: true docs_dir: './docs' From ada1f54b0f3f81899f5ff6d04e0fb4fdb3952cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Thu, 1 Feb 2024 00:12:16 -0300 Subject: [PATCH 57/66] Add response examples in index docs --- docs/basics.md | 18 +- docs/index.md | 598 ++++++++++++++++++++--- examples/hal/app.py | 4 +- examples/siren/app.py | 4 +- examples/url_for/app.py | 4 +- fastapi_hypermodel/hal/hal_hypermodel.py | 10 + 6 files changed, 556 insertions(+), 82 deletions(-) diff --git a/docs/basics.md b/docs/basics.md index a55a48f..2a6c838 100644 --- a/docs/basics.md +++ b/docs/basics.md @@ -158,7 +158,11 @@ our item list, and `ItemDetail` for full item information. items: Items = { "items": [ - {"id_": "item01", "name": "Foo", "price": 50.2}, + { + "id_": "item01", + "name": "Foo", + "price": 10.2 + }, { "id_": "item02", "name": "Bar", @@ -202,7 +206,11 @@ our item list, and `ItemDetail` for full item information. items: Items = { "sc:items": [ - {"id_": "item01", "name": "Foo", "price": 10.2}, + { + "id_": "item01", + "name": "Foo", + "price": 10.2 + }, { "id_": "item02", "name": "Bar", @@ -254,7 +262,11 @@ our item list, and `ItemDetail` for full item information. items: Items = { "items": [ - {"id_": "item01", "name": "Foo", "price": 10.2}, + { + "id_": "item01", + "name": "Foo", + "price": 10.2 + }, { "id_": "item02", "name": "Bar", diff --git a/docs/index.md b/docs/index.md index 74268f4..ddc5236 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,80 +13,527 @@ --- -**Documentation**: https://jtc42.github.io/fastapi-hypermodel/ +**Documentation**: https://jtc42.github.io/fastapi-hypermodel/ -**Source Code**: https://github.com/jtc42/fastapi-hypermodel +**Source Code**: https://github.com/jtc42/fastapi-hypermodel --- -FastAPI-HyperModel is a FastAPI + Pydantic extension for simplifying hypermedia-driven API development. - -This module adds a new Pydantic model base-class, supporting dynamic `href` generation based on object data. - - - - - - - - - - - - - - - - - -
ModelResponse
- -```python -class ItemSummary(HyperModel): - name: str - id: str - href = UrlFor( - "read_item", {"item_id": ""} - ) -``` - - - -```json -{ - "name": "Foo", - "id": "item01", - "href": "/items/item01" -} -``` - -
- -```python -class ItemSummary(HyperModel): - name: str - id: str - link = HALFor( - "read_item", {"item_id": ""}, - description="Read an item" - ) -``` - - - -```json -{ - "name": "Foo", - "id": "item01", - "link": { - "href": "/items/item01", - "method": "GET", - "description": "Read an item" - } -} -``` - -
+FastAPI-HyperModel is a FastAPI + Pydantic extension for simplifying +hypermedia-driven API development. + +Hypermedia consist of enriching API responses by providing links to other URIs +within the services to fetch related resources or perform certain actions. There +are several levels according to the [Hypermedia Maturity Model +Levels](https://8thlight.com/insights/the-hypermedia-maturity-model). Using +Hypermedia makes APIs reach Level 3 of the Richardson Maturity Model (RMM), +which involves leveraging Hypertext As The Engine Of Application State +(HATEOAS), that is, Hypermedia. + + +## Single Item Example + +=== "No Hypermedia" + + ```json + { + "id_": "item01", + "name": "Foo", + "price": 10.2, + } + ``` + +=== "Level 0 (URLFor)" + + ```json + { + "id_": "item01", + "name": "Foo", + "price": 10.2, + + "href": "/items/item01", + "update": "/items/item01" + } + ``` + +=== "Level 1 (HAL)" + + ```json + { + "id_": "item01", + "name": "Foo", + "price": 10.2, + + "_links": { + "self": {"href": "/items/item01"}, + "update": {"href": "/items/item01"}, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ], + }, + } + ``` + +=== "Level 2 (Siren)" + + ```json + { + "properties": { + "id_": "item01", + "name": "Foo", + "price": 10.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Foo" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "10.2" + } + ] + } + ] + } + ``` + +## Collection of Items Example + + +=== "No Hypermedia" + + ```json + { + "items": [ + { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 50.2, + }, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, + } + ], + } + ``` + +=== "Level 0 (URLFor)" + + ```json + { + "items": [ + { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 50.2, + + "href": "/items/item01", + "update": "/items/item01" + }, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, + + "href": "/items/item02", + "update": "/items/item02" + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + + "href": "/items/item03", + "update": "/items/item03" + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, + + "href": "/items/item04", + "update": "/items/item04" + } + ], + + "href": "/items", + "find": "/items/{id_}", + "update": "/items/{id_}" + } + ``` + +=== "Level 1 (HAL)" + + ```json + { + "_embedded": { + "sc:items": [ + { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2, + + "_links": { + "self": { + "href": "/items/item01" + }, + "update": { + "href": "/items/item01" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, + + "_links": { + "self": { + "href": "/items/item02" + }, + "update": { + "href": "/items/item02" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + + "_links": { + "self": { + "href": "/items/item03" + }, + "update": { + "href": "/items/item03" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, + + "_links": { + "self": { + "href": "/items/item04" + }, + "update": { + "href": "/items/item04" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ] + }, + + "_links": { + "self": { + "href": "/items" + }, + "find": { + "href": "/items/{id_}", + "templated": true + }, + "update": { + "href": "/items/{id_}", + "templated": true + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ``` + +=== "Level 2 (Siren)" + + ```json + { + "entities": [ + { + "properties": { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Foo" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "10.2" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item02" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item02", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Bar" + }, + { + "name": "description", + "type": "text", + "value": "The Bar fighters" + }, + { + "name": "price", + "type": "number", + "value": "62.0" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item03" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item03", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Baz" + }, + { + "name": "description", + "type": "text", + "value": "There goes my baz" + }, + { + "name": "price", + "type": "number", + "value": "50.2" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item04" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item04", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Doe" + }, + { + "name": "description", + "type": "text", + "value": "There goes my Doe" + }, + { + "name": "price", + "type": "number", + "value": "5.0" + } + ] + } + ], + "rel": ["items"] + } + ], + "links": [ + { + "rel": ["self"], + "href": "/items" + } + ], + "actions": [ + { + "name": "find", + "method": "GET", + "href": "/items/{id_}", + "templated": true + }, + { + "name": "update", + "method": "PUT", + "href": "/items/{id_}", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "None" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "None" + } + ], + "templated": true + } + ] + } + ``` ## Installation @@ -94,11 +541,16 @@ class ItemSummary(HyperModel): ## Limitations -Currently, query parameters will not resolve correctly. When generating a resource URL, ensure all parameters passed are path parameters, not query parameters. +Currently, query parameters will not resolve correctly. When generating a +resource URL, ensure all parameters passed are path parameters, not query +parameters. -This is an upstream issue, being tracked [here](https://github.com/encode/starlette/issues/560). +This is an upstream issue, being tracked +[here](https://github.com/encode/starlette/issues/560). ## Attributions -Some functionality is based on [Flask-Marshmallow](https://github.com/marshmallow-code/flask-marshmallow/blob/dev/src/flask_marshmallow/fields.py) `URLFor` class. +Some functionality is based on +[Flask-Marshmallow](https://github.com/marshmallow-code/flask-marshmallow/blob/dev/src/flask_marshmallow/fields.py) +`URLFor` class. diff --git a/examples/hal/app.py b/examples/hal/app.py index c2c9947..29ac972 100644 --- a/examples/hal/app.py +++ b/examples/hal/app.py @@ -17,8 +17,8 @@ class ItemSummary(HALHyperModel): - name: str id_: str + name: str links: HALLinks = FrozenDict({ "self": HALFor("read_item", {"id_": ""}), @@ -52,8 +52,8 @@ class ItemCollection(HALHyperModel): class Person(HALHyperModel): - name: str id_: str + name: str is_locked: bool items: Sequence[Item] = Field(alias="sc:items") diff --git a/examples/siren/app.py b/examples/siren/app.py index a854a4c..2ab3365 100644 --- a/examples/siren/app.py +++ b/examples/siren/app.py @@ -15,8 +15,8 @@ class ItemSummary(SirenHyperModel): - name: str id_: str + name: str links: Sequence[SirenLinkFor] = ( SirenLinkFor("read_item", {"id_": ""}, rel=["self"]), @@ -54,8 +54,8 @@ class ItemCollection(SirenHyperModel): class Person(SirenHyperModel): - name: str id_: str + name: str is_locked: bool items: Sequence[Item] diff --git a/examples/url_for/app.py b/examples/url_for/app.py index 0e0998e..412a44f 100644 --- a/examples/url_for/app.py +++ b/examples/url_for/app.py @@ -10,8 +10,8 @@ class ItemSummary(HyperModel): - name: str id_: str + name: str href: UrlFor = UrlFor("read_item", {"id_": ""}) update: UrlFor = UrlFor("update_item", {"id_": ""}) @@ -41,8 +41,8 @@ class ItemCollection(HyperModel): class Person(HyperModel): - name: str id_: str + name: str is_locked: bool items: Sequence[Item] diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index 6c3006f..26bff92 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -22,6 +22,7 @@ GetCoreSchemaHandler, PrivateAttr, field_serializer, + model_serializer, model_validator, ) from starlette.applications import Starlette @@ -53,6 +54,15 @@ class HALForType(BaseModel): def __bool__(self: Self) -> bool: return bool(self.href) + @model_serializer + def serialize(self: Self) -> Union[Mapping[str, Any], Sequence[Mapping[str, Any]]]: + if isinstance(self, Sequence): + return [ + {value.model_fields[k].alias or k: v for k, v in value if v} # type: ignore + for value in self + ] + return {self.model_fields[k].alias or k: v for k, v in self if v} + class HALFor(HALForType, AbstractHyperField[HALForType]): # pylint: disable=too-many-instance-attributes From d31ee4a1aa3f3ebf350bee1703fef5a53b3b777c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Thu, 1 Feb 2024 00:43:48 -0300 Subject: [PATCH 58/66] Add conditional examples to advanced docs --- docs/advanced.md | 537 ++++++++++++++++++++++++++++++++++++++++++ examples/hal/app.py | 2 - examples/siren/app.py | 3 - 3 files changed, 537 insertions(+), 5 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index e4b9aa0..b229f64 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -88,3 +88,540 @@ In this example, we use a lambda function that returns `True` or `False` dependi !!! note Conditional links will *always* show up in the auto-generated OpenAPI/Swagger documentation. These conditions *only* apply to the hypermedia fields generated at runtime. + + + +=== "URLFor" + + ```python + class Person(HyperModel): + id_: str + name: str + is_locked: bool + items: Sequence[Item] + + href: UrlFor = UrlFor("read_person", {"id_": ""}) + update: UrlFor = UrlFor("update_person", {"id_": ""}) + add_item: UrlFor = UrlFor( + "put_person_items", + {"id_": ""}, + condition=lambda values: not values["is_locked"], + ) + ``` + +=== "HAL" + + ```python + class Person(HALHyperModel): + id_: str + name: str + is_locked: bool + + items: Sequence[Item] = Field(alias="sc:items") + + links: HALLinks = FrozenDict({ + "self": HALFor("read_person", {"id_": ""}), + "update": HALFor("update_person", {"id_": ""}), + "add_item": HALFor( + "put_person_items", + {"id_": ""}, + condition=lambda values: not values["is_locked"], + ), + }) + ``` + +=== "Siren" + + ```python + class Person(SirenHyperModel): + id_: str + name: str + is_locked: bool + + items: Sequence[Item] + + links: Sequence[SirenLinkFor] = ( + SirenLinkFor("read_person", {"id_": ""}, rel=["self"]), + ) + + actions: Sequence[SirenActionFor] = ( + SirenActionFor("update_person", {"id_": ""}, name="update"), + SirenActionFor( + "put_person_items", + {"id_": ""}, + condition=lambda values: not values["is_locked"], + name="add_item", + populate_fields=False, + ), + ) + ``` + +## Response for unlocked Person + +=== "URLFor" + + ```json + { + "id_": "person01", + "name": "Alice", + "is_locked": false, + "items": [ + { + "id_": "item01", + "name": "Foo", + "href": "/items/item01", + "update": "/items/item01", + "description": null, + "price": 50.2 + }, + { + "id_": "item02", + "name": "Bar", + "href": "/items/item02", + "update": "/items/item02", + "description": "The Bar fighters", + "price": 62.0 + } + ], + + "href": "/people/person01", + "update": "/people/person01", + "add_item": "/people/person01/items" + } + ``` + +=== "HAL" + + ```json + { + "id_": "person01", + "name": "Alice", + "is_locked": false, + + "_embedded": { + "sc:items": [ + { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2, + + "_links": { + "self": { + "href": "/items/item01" + }, + "update": { + "href": "/items/item01" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, + + "_links": { + "self": { + "href": "/items/item02" + }, + "update": { + "href": "/items/item02" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ] + }, + "_links": { + "self": { + "href": "/people/person01" + }, + "update": { + "href": "/people/person01" + }, + "add_item": { + "href": "/people/person01/items" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ``` + +=== "Siren" + + ```json + { + "properties": { + "id_": "person01", + "name": "Alice", + "is_locked": false + }, + "entities": [ + { + "properties": { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Foo" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "10.2" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item02" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item02", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Bar" + }, + { + "name": "description", + "type": "text", + "value": "The Bar fighters" + }, + { + "name": "price", + "type": "number", + "value": "62.0" + } + ] + } + ], + "rel": ["items"] + } + ], + "links": [ + { + "rel": ["self"], + "href": "/people/person01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/people/person01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Alice" + }, + { + "name": "is_locked", + "type": "text", + "value": "None" + } + ] + }, + { + "name": "add_item", + "method": "PUT", + "href": "/people/person01/items", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "id_", + "type": "text" + } + ] + } + ] + } + ``` + + +## Response for locked Person + +=== "URLFor" + + ```json + { + "id_": "person02", + "name": "Bob", + "is_locked": true, + "items": [ + { + "id_": "item03", + "name": "Baz", + "href": "/items/item03", + "update": "/items/item03", + "description": "There goes my baz", + "price": 50.2 + }, + { + "id_": "item04", + "name": "Doe", + "href": "/items/item04", + "update": "/items/item04", + "description": "There goes my Doe", + "price": 5.0 + } + ], + "href": "/people/person02", + "update": "/people/person02" + } + ``` + +=== "HAL" + + ```json + { + "id_": "person02", + "name": "Bob", + "is_locked": true, + + "_embedded": { + "sc:items": [ + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + + "_links": { + "self": { + "href": "/items/item03" + }, + "update": { + "href": "/items/item03" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, + + "_links": { + "self": { + "href": "/items/item04" + }, + "update": { + "href": "/items/item04" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ] + }, + "_links": { + "self": { + "href": "/people/person02" + }, + "update": { + "href": "/people/person02" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ``` + +=== "Siren" + + ```json + { + "properties": { + "id_": "person02", + "name": "Bob", + "is_locked": true + }, + "entities": [ + { + "properties": { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item03" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item03", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Baz" + }, + { + "name": "description", + "type": "text", + "value": "There goes my baz" + }, + { + "name": "price", + "type": "number", + "value": "50.2" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item04" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item04", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Doe" + }, + { + "name": "description", + "type": "text", + "value": "There goes my Doe" + }, + { + "name": "price", + "type": "number", + "value": "5.0" + } + ] + } + ], + "rel": ["items"] + } + ], + "links": [ + { + "rel": ["self"], + "href": "/people/person02" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/people/person02", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Bob" + }, + { + "name": "is_locked", + "type": "text", + "value": "True" + } + ] + } + ] + } + ``` \ No newline at end of file diff --git a/examples/hal/app.py b/examples/hal/app.py index 29ac972..985c68f 100644 --- a/examples/hal/app.py +++ b/examples/hal/app.py @@ -64,7 +64,6 @@ class Person(HALHyperModel): "add_item": HALFor( "put_person_items", {"id_": ""}, - description="Add an item to this person and the items list", condition=lambda values: not values["is_locked"], ), }) @@ -80,7 +79,6 @@ class PersonCollection(HALHyperModel): ), "update": HALFor( "update_person", - description="Update a particular person", templated=True, ), }) diff --git a/examples/siren/app.py b/examples/siren/app.py index 2ab3365..6a19636 100644 --- a/examples/siren/app.py +++ b/examples/siren/app.py @@ -69,7 +69,6 @@ class Person(SirenHyperModel): SirenActionFor( "put_person_items", {"id_": ""}, - description="Add an item to this person and the items list", condition=lambda values: not values["is_locked"], name="add_item", populate_fields=False, @@ -85,13 +84,11 @@ class PersonCollection(SirenHyperModel): actions: Sequence[SirenActionFor] = ( SirenActionFor( "read_person", - description="Get a particular person", templated=True, name="find", ), SirenActionFor( "update_person", - description="Update a particular person", templated=True, name="update", ), From d4dba0b418d2f80bd676abdfe2c2e1142b200ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Thu, 1 Feb 2024 00:49:17 -0300 Subject: [PATCH 59/66] Improve readability --- docs/advanced.md | 4 ++++ examples/url_for/app.py | 1 + 2 files changed, 5 insertions(+) diff --git a/docs/advanced.md b/docs/advanced.md index b229f64..eae6126 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -98,6 +98,7 @@ In this example, we use a lambda function that returns `True` or `False` dependi id_: str name: str is_locked: bool + items: Sequence[Item] href: UrlFor = UrlFor("read_person", {"id_": ""}) @@ -165,6 +166,7 @@ In this example, we use a lambda function that returns `True` or `False` dependi "id_": "person01", "name": "Alice", "is_locked": false, + "items": [ { "id_": "item01", @@ -409,6 +411,7 @@ In this example, we use a lambda function that returns `True` or `False` dependi "id_": "person02", "name": "Bob", "is_locked": true, + "items": [ { "id_": "item03", @@ -427,6 +430,7 @@ In this example, we use a lambda function that returns `True` or `False` dependi "price": 5.0 } ], + "href": "/people/person02", "update": "/people/person02" } diff --git a/examples/url_for/app.py b/examples/url_for/app.py index 412a44f..991b9ed 100644 --- a/examples/url_for/app.py +++ b/examples/url_for/app.py @@ -44,6 +44,7 @@ class Person(HyperModel): id_: str name: str is_locked: bool + items: Sequence[Item] href: UrlFor = UrlFor("read_person", {"id_": ""}) From 1db4a40c2f90bfc08980be4818fdeb989cd4f94c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 3 Feb 2024 20:10:42 -0300 Subject: [PATCH 60/66] Sync tabs in code snippets --- docs/js/linked_tabs.js | 21 +++++++++++++++++++++ mkdocs.yml | 3 +++ 2 files changed, 24 insertions(+) create mode 100644 docs/js/linked_tabs.js diff --git a/docs/js/linked_tabs.js b/docs/js/linked_tabs.js new file mode 100644 index 0000000..1c703d9 --- /dev/null +++ b/docs/js/linked_tabs.js @@ -0,0 +1,21 @@ +const syncTabsByName = (tab) => () => { + const selected = document.querySelector(`label[for=${tab.id}]`) + const previousPosition = selected.getBoundingClientRect().top + const labelSelector = '.tabbed-set > label, .tabbed-alternate > .tabbed-labels > label' + + Array.from(document.querySelectorAll(labelSelector)) + .filter(label => label.innerText === selected.innerText) + .forEach(label => document.querySelector(`input[id=${label.getAttribute('for')}]`).click()) + + // Preserve scroll position + const currentPosition = selected.getBoundingClientRect().top + const delta = currentPosition - previousPosition + window.scrollBy(0, delta) +} + +const tabSync = () => { + document.querySelectorAll(".tabbed-set > input") + .forEach(tab => tab.addEventListener("click", syncTabsByName(tab))) +} + +tabSync(); \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 4056db5..0ad2e2e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,6 +7,9 @@ theme: repo_name: jtc42/fastapi-hypermodel repo_url: https://github.com/jtc42/fastapi-hypermodel +extra_javascript: + - js/linked_tabs.js + markdown_extensions: - admonition - pymdownx.highlight: From 01a70aeb23f508bd57cbb08b3d470f89ce351083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 3 Feb 2024 21:46:01 -0300 Subject: [PATCH 61/66] Improve docs with examples --- docs/advanced.md | 395 ++++++--------- docs/basics.md | 615 +++++++++++++++++++---- docs/extending.md | 166 +----- docs/index.md | 34 +- examples/siren/app.py | 2 +- fastapi_hypermodel/hal/hal_hypermodel.py | 6 - 6 files changed, 718 insertions(+), 500 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index eae6126..7152e97 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -1,100 +1,35 @@ # Advanced Usage -##  Conditional Links +In addition to what the standard for the format defines, fastapi-hypermodel has +some additional features, such as conditional links. -It is possible to add additional field-value-dependent conditions on links. For example, you may want certain links within a set to only appear if the application or session is in a state that allows that interaction. +##  Conditional Links -Let's begin with our `Person` example from earlier. +It is possible to add additional field-value-dependent conditions on links. For +example, you may want certain links within a set to only appear if the +application or session is in a state that allows that interaction. -```python -class Person(HyperModel): - id: str - name: str - items: List[ItemSummary] +A new model `Person` is defined below, a person has an `id`, a `name` and a +collection of `items`. Moreover, a person could be locked, meaning no new items +could be added, this is modeled by the `is_locked` flag. Each `Person` has three +references, `self` (`href` for `URLFor`), `update` and `add_item`. - href = UrlFor("read_person", {"person_id": ""}) - links = LinkSet( - { - "self": UrlFor("read_person", {"person_id": ""}), - "items": UrlFor("read_person_items", {"person_id": ""}), - } - ) -``` - -We may want a new link that corresponds to adding a new Item to the Person. - -```python hl_lines="11" -class Person(HyperModel): - id: str - name: str - items: List[ItemSummary] - - href = UrlFor("read_person", {"person_id": ""}) - links = LinkSet( - { - "self": UrlFor("read_person", {"person_id": ""}), - "items": UrlFor("read_person_items", {"person_id": ""}), - "addItem": UrlFor("put_person_items", {"person_id": ""},), - } - ) -``` - -However, we may want functionality where a Person can be "locked", and no new items added. We add a new field `is_locked` to our model. - -```python hl_lines="4" -class Person(HyperModel): - id: str - name: str - is_locked: bool - items: List[ItemSummary] - - href = UrlFor("read_person", {"person_id": ""}) - links = LinkSet( - { - "self": UrlFor("read_person", {"person_id": ""}), - "items": UrlFor("read_person_items", {"person_id": ""}), - "addItem": UrlFor("put_person_items", {"person_id": ""},), - } - ) -``` - -Now, if the Person is locked, the `addItem` link is no longer relevant. Querying it will result in a denied error, and so we *may* choose to remove it from the link set. -To do this, we will add a field-dependent condition to the link. - -```python hl_lines="15" -class Person(HyperModel): - id: str - name: str - is_locked: bool - items: List[ItemSummary] - - href = UrlFor("read_person", {"person_id": ""}) - links = LinkSet( - { - "self": UrlFor("read_person", {"person_id": ""}), - "items": UrlFor("read_person_items", {"person_id": ""}), - "addItem": UrlFor( - "put_person_items", - {"person_id": ""}, - condition=lambda values: not values["is_locked"], - ), - } - ) -``` -The `condition` argument takes a callable, which will be passed dict containing the name-to-value mapping of all fields on the parent `HyperModel` instance. -In this example, we use a lambda function that returns `True` or `False` depending on the value `is_locked` of the parent `HyperModel` instance. +The `condition` argument takes a callable, which will be passed a dict +containing the name-to-value mapping of all fields on the base `HyperModel` +instance. In this example, a lambda function that returns `True` or `False` +depending on the value `is_locked` of `HyperModel` instance. !!! note - Conditional links will *always* show up in the auto-generated OpenAPI/Swagger documentation. - These conditions *only* apply to the hypermedia fields generated at runtime. - + Conditional links will *always* show up in the auto-generated OpenAPI/Swagger + documentation. These conditions *only* apply to the hypermedia fields + generated at runtime. === "URLFor" - ```python - class Person(HyperModel): + ```python linenums="1" hl_lines="13" + class Person(HyperModel): id_: str name: str is_locked: bool @@ -112,7 +47,7 @@ In this example, we use a lambda function that returns `True` or `False` dependi === "HAL" - ```python + ```python linenums="1" hl_lines="14" class Person(HALHyperModel): id_: str name: str @@ -133,7 +68,7 @@ In this example, we use a lambda function that returns `True` or `False` dependi === "Siren" - ```python + ```python linenums="1" hl_lines="19" class Person(SirenHyperModel): id_: str name: str @@ -150,70 +85,70 @@ In this example, we use a lambda function that returns `True` or `False` dependi SirenActionFor( "put_person_items", {"id_": ""}, - condition=lambda values: not values["is_locked"], name="add_item", populate_fields=False, + condition=lambda values: not values["is_locked"], ), ) ``` -## Response for unlocked Person + +### Response for locked Person === "URLFor" - ```json + ```json linenums="1" { - "id_": "person01", - "name": "Alice", - "is_locked": false, + "id_": "person02", + "name": "Bob", + "is_locked": true, "items": [ { - "id_": "item01", - "name": "Foo", - "href": "/items/item01", - "update": "/items/item01", - "description": null, + "id_": "item03", + "name": "Baz", + "href": "/items/item03", + "update": "/items/item03", + "description": "There goes my baz", "price": 50.2 }, { - "id_": "item02", - "name": "Bar", - "href": "/items/item02", - "update": "/items/item02", - "description": "The Bar fighters", - "price": 62.0 + "id_": "item04", + "name": "Doe", + "href": "/items/item04", + "update": "/items/item04", + "description": "There goes my Doe", + "price": 5.0 } ], - "href": "/people/person01", - "update": "/people/person01", - "add_item": "/people/person01/items" + "href": "/people/person02", + "update": "/people/person02" } ``` === "HAL" - ```json + ```json linenums="1" { - "id_": "person01", - "name": "Alice", - "is_locked": false, + "id_": "person02", + "name": "Bob", + "is_locked": true, "_embedded": { "sc:items": [ { - "id_": "item01", - "name": "Foo", - "description": null, - "price": 10.2, + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, "_links": { "self": { - "href": "/items/item01" + "href": "/items/item03" }, "update": { - "href": "/items/item01" + "href": "/items/item03" }, "curies": [ { @@ -222,20 +157,20 @@ In this example, we use a lambda function that returns `True` or `False` dependi "name": "sc" } ] - } + } }, { - "id_": "item02", - "name": "Bar", - "description": "The Bar fighters", - "price": 62.0, + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, "_links": { "self": { - "href": "/items/item02" + "href": "/items/item04" }, "update": { - "href": "/items/item02" + "href": "/items/item04" }, "curies": [ { @@ -250,13 +185,10 @@ In this example, we use a lambda function that returns `True` or `False` dependi }, "_links": { "self": { - "href": "/people/person01" + "href": "/people/person02" }, "update": { - "href": "/people/person01" - }, - "add_item": { - "href": "/people/person01/items" + "href": "/people/person02" }, "curies": [ { @@ -271,48 +203,48 @@ In this example, we use a lambda function that returns `True` or `False` dependi === "Siren" - ```json + ```json linenums="1" { "properties": { - "id_": "person01", - "name": "Alice", - "is_locked": false + "id_": "person02", + "name": "Bob", + "is_locked": true }, "entities": [ { "properties": { - "id_": "item01", - "name": "Foo", - "description": null, - "price": 10.2 + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2 }, "links": [ { "rel": ["self"], - "href": "/items/item01" + "href": "/items/item03" } ], "actions": [ { "name": "update", "method": "PUT", - "href": "/items/item01", + "href": "/items/item03", "type": "application/x-www-form-urlencoded", "fields": [ { "name": "name", "type": "text", - "value": "Foo" + "value": "Baz" }, { "name": "description", "type": "text", - "value": "None" + "value": "There goes my baz" }, { "name": "price", "type": "number", - "value": "10.2" + "value": "50.2" } ] } @@ -321,38 +253,38 @@ In this example, we use a lambda function that returns `True` or `False` dependi }, { "properties": { - "id_": "item02", - "name": "Bar", - "description": "The Bar fighters", - "price": 62.0 + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0 }, "links": [ { "rel": ["self"], - "href": "/items/item02" + "href": "/items/item04" } ], "actions": [ { "name": "update", "method": "PUT", - "href": "/items/item02", + "href": "/items/item04", "type": "application/x-www-form-urlencoded", "fields": [ { "name": "name", "type": "text", - "value": "Bar" + "value": "Doe" }, { "name": "description", "type": "text", - "value": "The Bar fighters" + "value": "There goes my Doe" }, { "name": "price", "type": "number", - "value": "62.0" + "value": "5.0" } ] } @@ -363,37 +295,25 @@ In this example, we use a lambda function that returns `True` or `False` dependi "links": [ { "rel": ["self"], - "href": "/people/person01" + "href": "/people/person02" } ], "actions": [ { "name": "update", "method": "PUT", - "href": "/people/person01", + "href": "/people/person02", "type": "application/x-www-form-urlencoded", "fields": [ { "name": "name", "type": "text", - "value": "Alice" + "value": "Bob" }, { "name": "is_locked", "type": "text", - "value": "None" - } - ] - }, - { - "name": "add_item", - "method": "PUT", - "href": "/people/person01/items", - "type": "application/x-www-form-urlencoded", - "fields": [ - { - "name": "id_", - "type": "text" + "value": "True" } ] } @@ -401,63 +321,63 @@ In this example, we use a lambda function that returns `True` or `False` dependi } ``` - -## Response for locked Person +### Response for unlocked Person === "URLFor" - ```json + ```json linenums="1" hl_lines="27" { - "id_": "person02", - "name": "Bob", - "is_locked": true, + "id_": "person01", + "name": "Alice", + "is_locked": false, "items": [ { - "id_": "item03", - "name": "Baz", - "href": "/items/item03", - "update": "/items/item03", - "description": "There goes my baz", + "id_": "item01", + "name": "Foo", + "href": "/items/item01", + "update": "/items/item01", + "description": null, "price": 50.2 }, { - "id_": "item04", - "name": "Doe", - "href": "/items/item04", - "update": "/items/item04", - "description": "There goes my Doe", - "price": 5.0 + "id_": "item02", + "name": "Bar", + "href": "/items/item02", + "update": "/items/item02", + "description": "The Bar fighters", + "price": 62.0 } ], - "href": "/people/person02", - "update": "/people/person02" + "href": "/people/person01", + "update": "/people/person01", + "add_item": "/people/person01/items" } ``` === "HAL" - ```json + ```json linenums="1" hl_lines="61-63" { - "id_": "person02", - "name": "Bob", - "is_locked": true, + "id_": "person01", + "name": "Alice", + "is_locked": false, "_embedded": { "sc:items": [ { - "id_": "item03", - "name": "Baz", - "description": "There goes my baz", - "price": 50.2, + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2, "_links": { "self": { - "href": "/items/item03" + "href": "/items/item01" }, "update": { - "href": "/items/item03" + "href": "/items/item01" }, "curies": [ { @@ -466,20 +386,20 @@ In this example, we use a lambda function that returns `True` or `False` dependi "name": "sc" } ] - } + } }, { - "id_": "item04", - "name": "Doe", - "description": "There goes my Doe", - "price": 5.0, + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, "_links": { "self": { - "href": "/items/item04" + "href": "/items/item02" }, "update": { - "href": "/items/item04" + "href": "/items/item02" }, "curies": [ { @@ -494,10 +414,13 @@ In this example, we use a lambda function that returns `True` or `False` dependi }, "_links": { "self": { - "href": "/people/person02" + "href": "/people/person01" }, "update": { - "href": "/people/person02" + "href": "/people/person01" + }, + "add_item": { + "href": "/people/person01/items" }, "curies": [ { @@ -512,48 +435,48 @@ In this example, we use a lambda function that returns `True` or `False` dependi === "Siren" - ```json + ```json linenums="1" hl_lines="114-126" { "properties": { - "id_": "person02", - "name": "Bob", - "is_locked": true + "id_": "person01", + "name": "Alice", + "is_locked": false }, "entities": [ { "properties": { - "id_": "item03", - "name": "Baz", - "description": "There goes my baz", - "price": 50.2 + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2 }, "links": [ { "rel": ["self"], - "href": "/items/item03" + "href": "/items/item01" } ], "actions": [ { "name": "update", "method": "PUT", - "href": "/items/item03", + "href": "/items/item01", "type": "application/x-www-form-urlencoded", "fields": [ { "name": "name", "type": "text", - "value": "Baz" + "value": "Foo" }, { "name": "description", "type": "text", - "value": "There goes my baz" + "value": "None" }, { "name": "price", "type": "number", - "value": "50.2" + "value": "10.2" } ] } @@ -562,38 +485,38 @@ In this example, we use a lambda function that returns `True` or `False` dependi }, { "properties": { - "id_": "item04", - "name": "Doe", - "description": "There goes my Doe", - "price": 5.0 + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0 }, "links": [ { "rel": ["self"], - "href": "/items/item04" + "href": "/items/item02" } ], "actions": [ { "name": "update", "method": "PUT", - "href": "/items/item04", + "href": "/items/item02", "type": "application/x-www-form-urlencoded", "fields": [ { "name": "name", "type": "text", - "value": "Doe" + "value": "Bar" }, { "name": "description", "type": "text", - "value": "There goes my Doe" + "value": "The Bar fighters" }, { "name": "price", "type": "number", - "value": "5.0" + "value": "62.0" } ] } @@ -604,25 +527,37 @@ In this example, we use a lambda function that returns `True` or `False` dependi "links": [ { "rel": ["self"], - "href": "/people/person02" + "href": "/people/person01" } ], "actions": [ { "name": "update", "method": "PUT", - "href": "/people/person02", + "href": "/people/person01", "type": "application/x-www-form-urlencoded", "fields": [ { "name": "name", "type": "text", - "value": "Bob" + "value": "Alice" }, { "name": "is_locked", "type": "text", - "value": "True" + "value": "None" + } + ] + }, + { + "name": "add_item", + "method": "PUT", + "href": "/people/person01/items", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "id_", + "type": "text" } ] } diff --git a/docs/basics.md b/docs/basics.md index 2a6c838..f00af33 100644 --- a/docs/basics.md +++ b/docs/basics.md @@ -9,14 +9,15 @@ Levels](https://8thlight.com/insights/the-hypermedia-maturity-model): - Level 1: [Hypertext Application Language (HAL)](https://datatracker.ietf.org/doc/html/draft-kelly-json-hal) - Level 2: [Siren](https://github.com/kevinswiber/siren) -There is a fully working example for each format in the [examples](../examples) +There is a fully working example for each format in the +[examples](https://github.com/jtc42/fastapi-hypermodel/tree/main/examples) directory. ## Initialization === "URLFor" - ```python + ```python linenums="1" from fastapi import FastAPI from fastapi_hypermodel import HyperModel, UrlFor @@ -24,7 +25,7 @@ directory. === "HAL" - ```python + ```python linenums="1" from fastapi import FastAPI from fastapi_hypermodel import ( @@ -38,7 +39,7 @@ directory. === "Siren" - ```python + ```python linenums="1" from fastapi import FastAPI from fastapi_hypermodel import ( @@ -49,19 +50,34 @@ directory. ) ``` -## Create your basic models +## Create Basic Models -We'll create two models, a brief item summary including ID, name, and a link, -and a full model containing additional information. We'll use `ItemSummary` in -our item list, and `ItemDetail` for full item information. +Two showcase the hypermedia feature, an `Item` model will be used. Each item +will have an `id_`, a `name`, an optional `description` and a `price`. Moreover +a `ItemCollection` will also be defined to return multiple items. Two hypermedia +references will be used, one called `self` (`href` in the case of `URLFor`) and +an `update`. + +All formats support "links", that is, plain references of HTTP URIs fetchable +via GET. Moreover, Level 2 formats (SIREN) support "actions", which also specify +the HTTP method and the fields needed. + +Even though not part of the standard, fastapi-hypermodel provides support for +"templated URIs". Allowing the client to form the URI with information from the +selected resource. This is useful when returning collections. + +!!! info + + The reason to define two classes `ItemSummary` and `Item` is to enable using + a lightweight version (`ItemSummary`) for nested objects === "URLFor" - ```python + ```python linenums="1" class ItemSummary(HyperModel): - name: str id_: str + name: str href: UrlFor = UrlFor("read_item", {"id_": ""}) update: UrlFor = UrlFor("update_item", {"id_": ""}) @@ -80,10 +96,10 @@ our item list, and `ItemDetail` for full item information. === "HAL" - ```python + ```python linenums="1" class ItemSummary(HALHyperModel): - name: str id_: str + name: str links: HALLinks = FrozenDict({ "self": HALFor("read_item", {"id_": ""}), @@ -106,10 +122,10 @@ our item list, and `ItemDetail` for full item information. === "Siren" - ```python + ```python linenums="1" class ItemSummary(SirenHyperModel): - name: str id_: str + name: str links: Sequence[SirenLinkFor] = ( SirenLinkFor("read_item", {"id_": ""}, rel=["self"]), @@ -137,9 +153,20 @@ our item list, and `ItemDetail` for full item information. ## Define your data +Before defining the app and the endpoints, sample data should be defined. In +this case all formats will use the same data. + +In the case of HAL, to showcase the "cURIes" feature the data will change and +use `sc:items` instead of `items` as the key. At the moment only HAL supports +"cURIes" as part of the standard. + +It is important to note that none of the additional fields added to the response +at runtime are leaked into the data implementation. Therefore, the hypermedia +format and the data model are totally decoupled, granting great flexibility. + === "URLFor" - ```python + ```python linenums="1" from typing import List from typing_extensions import NotRequired, TypedDict @@ -187,7 +214,7 @@ our item list, and `ItemDetail` for full item information. === "HAL" - ```python + ```python linenums="1" from typing import List from typing_extensions import NotRequired, TypedDict @@ -243,7 +270,7 @@ our item list, and `ItemDetail` for full item information. === "Siren" - ```python + ```python linenums="1" from typing import List from typing_extensions import NotRequired, TypedDict @@ -290,20 +317,26 @@ our item list, and `ItemDetail` for full item information. ``` -## Create and attach your app +## Create and Attach App + +To make the app "hypermedia-aware", it is enough to initiliaze the format's +HyperModel class with the app object. + +!!! warning -We'll now create our FastAPI app, and bind it to our `HyperModel` base class. + At the moment this is handled by class variables so it is not thread-safe to + have multiple apps. === "URLFor" - ```python + ```python linenums="1" app = FastAPI() HyperModel.init_app(app) ``` === "HAL" - ```python + ```python linenums="1" app = FastAPI() HALHyperModel.init_app(app) HALHyperModel.register_curies(curies) @@ -311,20 +344,23 @@ We'll now create our FastAPI app, and bind it to our `HyperModel` base class. === "Siren" - ```python + ```python linenums="1" app = FastAPI() SirenHyperModel.init_app(app) ``` -## Add some API views +## Add API Endpoints -We'll create an API view for a list of items, as well as details about an -individual item. Note that we pass the item ID with our `{item_id}` URL -variable. +To expose the data via endpoints, they are defined as usual in any FastAPI app. +The `response_model` and `response_class` need to be defined when appropiate. + +All formats are compatible with path parameters. In the case of Level 2 formats +(SIREN), it can auto detect path and body parameters as well. Query parameters +are not well supported yet. === "URLFor" - ```python + ```python linenums="1" @app.get("/items", response_model=ItemCollection) def read_items() -> Any: return items @@ -336,7 +372,7 @@ variable. === "HAL" - ```python + ```python linenums="1" @app.get("/items", response_model=ItemCollection, response_class=HALResponse) def read_items() -> Any: return items @@ -348,7 +384,7 @@ variable. === "Siren" - ```python + ```python linenums="1" @app.get("/items", response_model=ItemCollection, response_class=SirenResponse) def read_items() -> Any: return items @@ -358,109 +394,478 @@ variable. return next(item for item in items["items"] if item["id_"] == id_) ``` -## Create a model `href` -We'll now go back and add an `href` field with a special `UrlFor` value. This -`UrlFor` class defines how our href elements will be generated. We'll change our -`ItemSummary` class to: +## Responses -```python -class ItemSummary(HyperModel): - name: str - id: str - href = UrlFor("read_item", {"item_id": ""}) -``` +The response generated by each format varies based on their specification. Using +hypermedia usually results in heavier responses because of all the additional +information provided. -The `UrlFor` class takes two arguments: +!!! warning -### `endpoint` + At the moment no optimizations are done under the hood to minimize the size + of the response. For instance, one such optimization could be removing + cURIes in HAL if they are already defined in a parent. -Name of your FastAPI endpoint function you want to link to. In our example, we -want our item summary to link to the corresponding item detail page, which maps -to our `read_item` function. + Beware of highly nested objects. -Alternatively, rather than providing the endpoint name, you can provide a -reference to the endpoint function itself, for example `UrlFor(read_item, -{"item_id": ""})`. This can help with larger projects where function names -may be refactored. -### `values` (optional depending on endpoint) +### Fetching /items/item01 -Same keyword arguments as FastAPI's url_path_for, except string arguments -enclosed in < > will be interpreted as attributes to pull from the object. For -example, here we need to pass an `item_id` argument as required by our endpoint -function, and we want to populate that with our item object's `id` attribute. -## Create a link set +=== "URLFor" -In some cases we want to create a map of relational links. In these cases we can -create a `LinkSet` field describing each link and it's relationship to the -object. The `LinkSet` class is really just a spicy dictionary that tells the -parent `HyperModel` to "render" each link in the link set, and includes some -extra OpenAPI schema stuff. + ```json linenums="1" + { + "id_": "item01", + "name": "Foo", + "price": 10.2, -```python -class Person(HyperModel): - id: str - name: str - items: List[ItemSummary] + "href": "/items/item01", + "update": "/items/item01" + } + ``` - href = UrlFor("read_person", {"person_id": ""}) - links = LinkSet( - { - "self": UrlFor("read_person", {"person_id": ""}), - "items": UrlFor("read_person_items", {"person_id": ""}), - } - ) -``` +=== "HAL" -## Putting it all together + ```json linenums="1" + { + "id_": "item01", + "name": "Foo", + "price": 10.2, + + "_links": { + "self": {"href": "/items/item01"}, + "update": {"href": "/items/item01"}, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ], + }, + } + ``` -For this example, we can make a dictionary containing some fake data, and add -extra models, even nesting models if we want. A complete example based on this -documentation can be found -[here](https://github.com/jtc42/fastapi-hypermodel/blob/main/examples/simple_app.py). +=== "Siren" -If we run the example application and go to our `/items` URL, we should get a -response like: + ```json linenums="1" + { + "properties": { + "id_": "item01", + "name": "Foo", + "price": 10.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Foo" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "10.2" + } + ] + } + ] + } + ``` -```json -[ - { - "name": "Foo", - "id": "item01", - "href": "/items/item01" - }, - { - "name": "Bar", - "id": "item02", - "href": "/items/item02" - }, - { - "name": "Baz", - "id": "item03", - "href": "/items/item03" - } -] -``` +### Fetching /items +=== "URLFor" + ```json linenums="1" + { + "items": [ + { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 50.2, -=== "URLFor" + "href": "/items/item01", + "update": "/items/item01" + }, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, - ```python + "href": "/items/item02", + "update": "/items/item02" + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + "href": "/items/item03", + "update": "/items/item03" + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, + + "href": "/items/item04", + "update": "/items/item04" + } + ], + + "href": "/items", + "find": "/items/{id_}", + "update": "/items/{id_}" + } ``` === "HAL" - ```python - + ```json linenums="1" + { + "_embedded": { + "sc:items": [ + { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2, + + "_links": { + "self": { + "href": "/items/item01" + }, + "update": { + "href": "/items/item01" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0, + + "_links": { + "self": { + "href": "/items/item02" + }, + "update": { + "href": "/items/item02" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2, + + "_links": { + "self": { + "href": "/items/item03" + }, + "update": { + "href": "/items/item03" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + }, + { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0, + + "_links": { + "self": { + "href": "/items/item04" + }, + "update": { + "href": "/items/item04" + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } + ] + }, + + "_links": { + "self": { + "href": "/items" + }, + "find": { + "href": "/items/{id_}", + "templated": true + }, + "update": { + "href": "/items/{id_}", + "templated": true + }, + "curies": [ + { + "href": "https://schema.org/{rel}", + "templated": true, + "name": "sc" + } + ] + } + } ``` === "Siren" - ```python - - ``` \ No newline at end of file + ```json linenums="1" + { + "entities": [ + { + "properties": { + "id_": "item01", + "name": "Foo", + "description": null, + "price": 10.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Foo" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "10.2" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item02", + "name": "Bar", + "description": "The Bar fighters", + "price": 62.0 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item02" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item02", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Bar" + }, + { + "name": "description", + "type": "text", + "value": "The Bar fighters" + }, + { + "name": "price", + "type": "number", + "value": "62.0" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item03", + "name": "Baz", + "description": "There goes my baz", + "price": 50.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item03" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item03", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Baz" + }, + { + "name": "description", + "type": "text", + "value": "There goes my baz" + }, + { + "name": "price", + "type": "number", + "value": "50.2" + } + ] + } + ], + "rel": ["items"] + }, + { + "properties": { + "id_": "item04", + "name": "Doe", + "description": "There goes my Doe", + "price": 5.0 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item04" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item04", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Doe" + }, + { + "name": "description", + "type": "text", + "value": "There goes my Doe" + }, + { + "name": "price", + "type": "number", + "value": "5.0" + } + ] + } + ], + "rel": ["items"] + } + ], + "links": [ + { + "rel": ["self"], + "href": "/items" + } + ], + "actions": [ + { + "name": "find", + "method": "GET", + "href": "/items/{id_}", + "templated": true + }, + { + "name": "update", + "method": "PUT", + "href": "/items/{id_}", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "None" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "None" + } + ], + "templated": true + } + ] + } + ``` diff --git a/docs/extending.md b/docs/extending.md index 976c779..1134a98 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -1,141 +1,27 @@ -# Extending FastAPI-HyperModel +# Extending + +It is possible to define new custom hypermedia formats. There are three aspects +to consider: + +- Skeleton Type: this is a class to for the underlying representation of the + fields in the response, it could be a single class (`URLForType`, + `HALForType`) or mutiple classes (`SirenActionType`, `SirenEmbeddedType`, + `SirenFieldType`, `SirenLinkType`). This skeleton type has the `Type` suffix + as a convention but it is not required. +- Builder Type: this is a helper class that inspects the app and gathers all the + necessary information to build the skeleton type. It it recommended to make it + a subclass of the skeleton type and also inherit from + `AbstractHyperField[SkeletonType]`. Some examples are `URLFor` and `HALFor` +- Hypermodel Type: This is an optional class to include response-wide logic. + `URLFor` has no Hypermodel type and leverages the base one, whereas HAL + implements `HALHyperModel` and uses it to handle the cURIes logic. Siren uses + the `SirenHyperModel` to move the different fields into `properties` and + `entitites`. This is usually required for Level 1+ Hypermdia formats +- Response Type: This is an optional class to define custom response behaviour. + It could be lightweight like `SirenResponse` where only a jsonchema is + checked, or it could be more complex like `HALReponse`. If no custom + content-type is needed, it could be omitted, as it happens with `URLFor`. + +All the formats (URLFor, HAL and Siren) are implemented in the same way a custom +type could be implemented. -The `HyperModel` class works by adding a root validator that iterates through each field on the instance being validated, and checks if it is an instance of `AbstractHyperField`. - -If an instance of `AbstractHyperField` is found, its `__build_hypermedia__` method is called, and the returned value will be substituted into the validated `HyperModel` instance. - -## Creating a new link class - -In most respects, a custom link class should be treated as a [custom Pydantic data type](https://pydantic-docs.helpmanual.io/usage/types/#custom-data-types). It can inherit from any Pydantic-compatible type, include any custom validation required, but *must* also inherit from `AbstractHyperField` and include a `__build_hypermedia__` method. - -This method must accept two arguments, an *optional* `fastapi.FastAPI` instance (optional only because prior to `HyperModel.init_app` being called, it will evaluate to `None`), and a dict containing the name-to-value mapping of all fields on the parent `HyperModel` instance. - -As an example, we'll re-implement the basic `URLFor` class from scratch. - -### Create a basic custom Pydantic class - -First we'll create a subclass of `UrlType` which accepts an endpoint string, and a dictionary of URL parameter to field mappings (see [Basic Usage](basics.md)). - -```python -from fastapi_hypermodel.hypermodel import UrlType - -class UrlFor(UrlType): - def __init__(self, endpoint: str, param_values: Optional[Dict[str, str]] = None): - self.endpoint: str = endpoint - self.param_values: Dict[str, str] = param_values or {} - super().__init__() -``` - -Next, we'll add out basic Pydantic validation functionality: - -```python hl_lines="10-12 14-16 18-30" -from fastapi_hypermodel.hypermodel import UrlType -from starlette.datastructures import URLPath - -class UrlFor(UrlType): - def __init__(self, endpoint: str, param_values: Optional[Dict[str, str]] = None): - self.endpoint: str = endpoint - self.param_values: Dict[str, str] = param_values or {} - super().__init__() - - @no_type_check - def __new__(cls, *_): - return str.__new__(cls) - - @classmethod - def __get_validators__(cls): - yield cls.validate - - @classmethod - def validate(cls, value: Any) -> "UrlFor": - """ - Validate UrlFor object against itself. - The UrlFor field type will only accept UrlFor instances. - """ - # Return original object if it's already a UrlFor instance - if value.__class__ == URLPath: - return value - # Otherwise raise an exception - raise ValueError( - f"UrlFor field should resolve to a starlette.datastructures.URLPath instance. Instead got {value.__class__}" - ) -``` - -At this point, our custom type will behave as a normal Pydantic type, but won't do any hypermedia substitutions. -For this, we must add our "magic" `__build_hypermedia__` method. - -```python hl_lines="32-38" -from fastapi_hypermodel.hypermodel import UrlType, resolve_param_values -from starlette.datastructures import URLPath - -class UrlFor(UrlType, AbstractHyperField): - def __init__(self, endpoint: str, param_values: Optional[Dict[str, str]] = None): - self.endpoint: str = endpoint - self.param_values: Dict[str, str] = param_values or {} - super().__init__() - - @no_type_check - def __new__(cls, *_): - return str.__new__(cls) - - @classmethod - def __get_validators__(cls): - yield cls.validate - - @classmethod - def validate(cls, value: Any) -> "UrlFor": - """ - Validate UrlFor object against itself. - The UrlFor field type will only accept UrlFor instances. - """ - # Return original object if it's already a UrlFor instance - if value.__class__ == URLPath: - return value - # Otherwise raise an exception - raise ValueError( - f"UrlFor field should resolve to a starlette.datastructures.URLPath instance. Instead got {value.__class__}" - ) - - def __build_hypermedia__( - self, app: Optional[FastAPI], values: Dict[str, Any] - ) -> Optional[str]: - if app is None: - return None - resolved_params = resolve_param_values(self.param_values, values) - return app.url_path_for(self.endpoint, **resolved_params) -``` - -Here we see that, as expected, our method accepts a `FastAPI` instance, and our dict of parent field values. We pass these field values, along with the URL parameter to field mappings, to a `resolve_param_values` function. This function takes our URL parameter to field mappings, and substitutes in the *actual* values from the parent. - -We can then pass this new dictionary of URL parameters to the FastAPI `url_path_for` function to generate a valid URL for this specific endpoint with this specific set of values. - -##  Creating a new link set class - -In FastAPI-HyperModel, a link set is essentially just another subclass of `AbstractHyperField`. The dictionary of returned links is generated by recursively calling `__build_hypermedia__` for each item, in the `__build_hypermedia__` method of the link set itself. - -This is most easily explained by analysing the source code for the built-in `LinkSet` class. - -```python -_LinkSetType = Dict[str, AbstractHyperField] - -class LinkSet(_LinkSetType, AbstractHyperField): # pylint: disable=too-many-ancestors - @classmethod - def __get_validators__(cls): - yield dict_validator - - @classmethod - def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: - field_schema.update({"additionalProperties": _uri_schema}) - - def __build_hypermedia__( - self, app: Optional[FastAPI], values: Dict[str, Any] - ) -> Dict[str, str]: - return {k: u.__build_hypermedia__(app, values) for k, u in self.items()} # type: ignore # pylint: disable=no-member -``` - -This class behaves link a standard dictionary, with `str` keys and any other `AbstractHyperField` as values. This allows, for example, nesting `LinkSet` instances for rich, deep hypermedia, as well as allowing different hypermedia types (such as `HALFor` links). - -The `__get_validators__` and `__modify_schema__` handle standard Pydantic functionality. -Within `__build_hypermedia__`, we simply return a dictionary of key-value pairs, where each value is generated by calling the item's `__build_hypermedia__` method. - -By overriding this method, it's possible to create entirely new formats of link sets as required. diff --git a/docs/index.md b/docs/index.md index ddc5236..afe39b4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,16 +28,21 @@ Hypermedia consist of enriching API responses by providing links to other URIs within the services to fetch related resources or perform certain actions. There are several levels according to the [Hypermedia Maturity Model Levels](https://8thlight.com/insights/the-hypermedia-maturity-model). Using -Hypermedia makes APIs reach Level 3 of the Richardson Maturity Model (RMM), -which involves leveraging Hypertext As The Engine Of Application State -(HATEOAS), that is, Hypermedia. +Hypermedia makes APIs reach Level 3 of the [Richardson Maturity Model +(RMM)](https://en.wikipedia.org/wiki/Richardson_Maturity_Model), which involves +leveraging [Hypertext As The Engine Of Application State +(HATEOAS)](https://en.wikipedia.org/wiki/HATEOAS), that is, Hypermedia. + +Below are two examples of how implementing hypermedia changes the responses in +different formats. The first example is for singular elements whereas the second +is for collections. ## Single Item Example === "No Hypermedia" - ```json + ```json linenums="1" { "id_": "item01", "name": "Foo", @@ -47,7 +52,7 @@ which involves leveraging Hypertext As The Engine Of Application State === "Level 0 (URLFor)" - ```json + ```json linenums="1" { "id_": "item01", "name": "Foo", @@ -60,7 +65,7 @@ which involves leveraging Hypertext As The Engine Of Application State === "Level 1 (HAL)" - ```json + ```json linenums="1" { "id_": "item01", "name": "Foo", @@ -82,7 +87,7 @@ which involves leveraging Hypertext As The Engine Of Application State === "Level 2 (Siren)" - ```json + ```json linenums="1" { "properties": { "id_": "item01", @@ -128,7 +133,7 @@ which involves leveraging Hypertext As The Engine Of Application State === "No Hypermedia" - ```json + ```json linenums="1" { "items": [ { @@ -161,7 +166,7 @@ which involves leveraging Hypertext As The Engine Of Application State === "Level 0 (URLFor)" - ```json + ```json linenums="1" { "items": [ { @@ -210,7 +215,7 @@ which involves leveraging Hypertext As The Engine Of Application State === "Level 1 (HAL)" - ```json + ```json linenums="1" { "_embedded": { "sc:items": [ @@ -330,7 +335,7 @@ which involves leveraging Hypertext As The Engine Of Application State === "Level 2 (Siren)" - ```json + ```json linenums="1" { "entities": [ { @@ -547,10 +552,3 @@ parameters. This is an upstream issue, being tracked [here](https://github.com/encode/starlette/issues/560). - -## Attributions - -Some functionality is based on -[Flask-Marshmallow](https://github.com/marshmallow-code/flask-marshmallow/blob/dev/src/flask_marshmallow/fields.py) -`URLFor` class. - diff --git a/examples/siren/app.py b/examples/siren/app.py index 6a19636..5091799 100644 --- a/examples/siren/app.py +++ b/examples/siren/app.py @@ -69,9 +69,9 @@ class Person(SirenHyperModel): SirenActionFor( "put_person_items", {"id_": ""}, - condition=lambda values: not values["is_locked"], name="add_item", populate_fields=False, + condition=lambda values: not values["is_locked"], ), ) diff --git a/fastapi_hypermodel/hal/hal_hypermodel.py b/fastapi_hypermodel/hal/hal_hypermodel.py index 26bff92..9e0ae31 100644 --- a/fastapi_hypermodel/hal/hal_hypermodel.py +++ b/fastapi_hypermodel/hal/hal_hypermodel.py @@ -82,7 +82,6 @@ def __init__( self: Self, endpoint: Union[HasName, str], param_values: Optional[Mapping[str, str]] = None, - description: Optional[str] = None, condition: Optional[Callable[[Mapping[str, Any]], bool]] = None, templated: Optional[bool] = None, title: Optional[str] = None, @@ -97,7 +96,6 @@ def __init__( endpoint.__name__ if isinstance(endpoint, HasName) else endpoint ) self._param_values = param_values or {} - self._description = description self._condition = condition self._templated = templated self._title = title @@ -245,7 +243,3 @@ def serialize_links(links: HALLinks) -> Dict[str, HALLinkType]: if not links: return {} return dict(links.items()) - - -EmbeddedRawType = Union[Mapping[str, Union[Sequence[Any], Any]], Any] -LinksRawType = Union[Mapping[str, Union[Any, Sequence[Any]]], Any] From 9dbe8cfad471e673139938e33af7baee29acbfa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 3 Feb 2024 21:51:57 -0300 Subject: [PATCH 62/66] Update Readme with response examples --- README.md | 132 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 99 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 4b0439c..e69ad2e 100644 --- a/README.md +++ b/README.md @@ -19,70 +19,137 @@ --- -FastAPI-HyperModel is a FastAPI + Pydantic extension for simplifying hypermedia-driven API development. +FastAPI-HyperModel is a FastAPI + Pydantic extension for simplifying +hypermedia-driven API development. -This module adds a new Pydantic model base-class, supporting dynamic `href` generation based on object data. +Hypermedia consist of enriching API responses by providing links to other URIs +within the services to fetch related resources or perform certain actions. There +are several levels according to the [Hypermedia Maturity Model +Levels](https://8thlight.com/insights/the-hypermedia-maturity-model). Using +Hypermedia makes APIs reach Level 3 of the [Richardson Maturity Model +(RMM)](https://en.wikipedia.org/wiki/Richardson_Maturity_Model), which involves +leveraging [Hypertext As The Engine Of Application State +(HATEOAS)](https://en.wikipedia.org/wiki/HATEOAS), that is, Hypermedia. + +Below are some examples of responses using hypermedia. For detailed examples, +check the docs. - + - + + + + + + + + @@ -102,4 +169,3 @@ This is an upstream issue, being tracked [here](https://github.com/encode/starle Huge thanks to [@christoe](https://github.com/christoe) for building support for Pydantic 2. -Some functionality is based on [Flask-Marshmallow](https://github.com/marshmallow-code/flask-marshmallow/blob/dev/src/flask_marshmallow/fields.py) `URLFor` class. From 576e6c2c62efd125e548e0d56498ef58559bbb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Sat, 3 Feb 2024 22:05:47 -0300 Subject: [PATCH 63/66] Fix Failing tests --- examples/hal/app.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/hal/app.py b/examples/hal/app.py index 985c68f..5328db3 100644 --- a/examples/hal/app.py +++ b/examples/hal/app.py @@ -74,9 +74,7 @@ class PersonCollection(HALHyperModel): links: HALLinks = FrozenDict({ "self": HALFor("read_people"), - "find": HALFor( - "read_person", description="Get a particular person", templated=True - ), + "find": HALFor("read_person", templated=True), "update": HALFor( "update_person", templated=True, From 6bf5623a62a464a0df0d4d80f509c1cbda140b38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Feb 2024 12:15:39 +0000 Subject: [PATCH 64/66] Bump ruff from 0.1.14 to 0.2.1 Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.14 to 0.2.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.14...v0.2.1) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- poetry.lock | 39 ++++++++++++++++++++------------------- pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/poetry.lock b/poetry.lock index eb1c7ae..d36436d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1328,6 +1328,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -1643,28 +1644,28 @@ files = [ [[package]] name = "ruff" -version = "0.1.14" +version = "0.2.1" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.1.14-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:96f76536df9b26622755c12ed8680f159817be2f725c17ed9305b472a757cdbb"}, - {file = "ruff-0.1.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab3f71f64498c7241123bb5a768544cf42821d2a537f894b22457a543d3ca7a9"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7060156ecc572b8f984fd20fd8b0fcb692dd5d837b7606e968334ab7ff0090ab"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a53d8e35313d7b67eb3db15a66c08434809107659226a90dcd7acb2afa55faea"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bea9be712b8f5b4ebed40e1949379cfb2a7d907f42921cf9ab3aae07e6fba9eb"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2270504d629a0b064247983cbc495bed277f372fb9eaba41e5cf51f7ba705a6a"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80258bb3b8909b1700610dfabef7876423eed1bc930fe177c71c414921898efa"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:653230dd00aaf449eb5ff25d10a6e03bc3006813e2cb99799e568f55482e5cae"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b3acc6c4e6928459ba9eb7459dd4f0c4bf266a053c863d72a44c33246bfdbf"}, - {file = "ruff-0.1.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b3dadc9522d0eccc060699a9816e8127b27addbb4697fc0c08611e4e6aeb8b5"}, - {file = "ruff-0.1.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1c8eca1a47b4150dc0fbec7fe68fc91c695aed798532a18dbb1424e61e9b721f"}, - {file = "ruff-0.1.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:62ce2ae46303ee896fc6811f63d6dabf8d9c389da0f3e3f2bce8bc7f15ef5488"}, - {file = "ruff-0.1.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b2027dde79d217b211d725fc833e8965dc90a16d0d3213f1298f97465956661b"}, - {file = "ruff-0.1.14-py3-none-win32.whl", hash = "sha256:722bafc299145575a63bbd6b5069cb643eaa62546a5b6398f82b3e4403329cab"}, - {file = "ruff-0.1.14-py3-none-win_amd64.whl", hash = "sha256:e3d241aa61f92b0805a7082bd89a9990826448e4d0398f0e2bc8f05c75c63d99"}, - {file = "ruff-0.1.14-py3-none-win_arm64.whl", hash = "sha256:269302b31ade4cde6cf6f9dd58ea593773a37ed3f7b97e793c8594b262466b67"}, - {file = "ruff-0.1.14.tar.gz", hash = "sha256:ad3f8088b2dfd884820289a06ab718cde7d38b94972212cc4ba90d5fbc9955f3"}, + {file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dd81b911d28925e7e8b323e8d06951554655021df8dd4ac3045d7212ac4ba080"}, + {file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dc586724a95b7d980aa17f671e173df00f0a2eef23f8babbeee663229a938fec"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c92db7101ef5bfc18e96777ed7bc7c822d545fa5977e90a585accac43d22f18a"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13471684694d41ae0f1e8e3a7497e14cd57ccb7dd72ae08d56a159d6c9c3e30e"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a11567e20ea39d1f51aebd778685582d4c56ccb082c1161ffc10f79bebe6df35"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:00a818e2db63659570403e44383ab03c529c2b9678ba4ba6c105af7854008105"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be60592f9d218b52f03384d1325efa9d3b41e4c4d55ea022cd548547cc42cd2b"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd2288890b88e8aab4499e55148805b58ec711053588cc2f0196a44f6e3d855"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ef052283da7dec1987bba8d8733051c2325654641dfe5877a4022108098683"}, + {file = "ruff-0.2.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7022d66366d6fded4ba3889f73cd791c2d5621b2ccf34befc752cb0df70f5fad"}, + {file = "ruff-0.2.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0a725823cb2a3f08ee743a534cb6935727d9e47409e4ad72c10a3faf042ad5ba"}, + {file = "ruff-0.2.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0034d5b6323e6e8fe91b2a1e55b02d92d0b582d2953a2b37a67a2d7dedbb7acc"}, + {file = "ruff-0.2.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e5cb5526d69bb9143c2e4d2a115d08ffca3d8e0fddc84925a7b54931c96f5c02"}, + {file = "ruff-0.2.1-py3-none-win32.whl", hash = "sha256:6b95ac9ce49b4fb390634d46d6ece32ace3acdd52814671ccaf20b7f60adb232"}, + {file = "ruff-0.2.1-py3-none-win_amd64.whl", hash = "sha256:e3affdcbc2afb6f5bd0eb3130139ceedc5e3f28d206fe49f63073cb9e65988e0"}, + {file = "ruff-0.2.1-py3-none-win_arm64.whl", hash = "sha256:efababa8e12330aa94a53e90a81eb6e2d55f348bc2e71adbf17d9cad23c03ee6"}, + {file = "ruff-0.2.1.tar.gz", hash = "sha256:3b42b5d8677cd0c72b99fcaf068ffc62abb5a19e71b4a3b9cfa50658a0af02f1"}, ] [[package]] @@ -1893,4 +1894,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.8,<4.0" -content-hash = "725092e0e0479e8d2a545aa3af450280f294317f4c92a5b5dfe4a8f0d0893f4f" +content-hash = "09dad0c8c5ea86215f98c1e4d5fab27bef232c4c7d0800e33084781b66fade12" diff --git a/pyproject.toml b/pyproject.toml index ed11b2f..6d0d8aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ pytest = "^7.0" pytest-cov = ">=3,<5" pytest-lazy-fixtures = ">=1.0.1" requests = "^2.25.1" -ruff = "^0.1.8" +ruff = ">=0.1.8,<0.3.0" tox = "^4.4" uvicorn = ">=0.17.6,<0.26.0" From a75d37aea9f6d9fd9823872a4f3a75c85b883c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Mon, 12 Feb 2024 20:21:38 +0000 Subject: [PATCH 65/66] Remove lock file from repository --- poetry.lock | 1919 --------------------------------------------------- 1 file changed, 1919 deletions(-) delete mode 100644 poetry.lock diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 898ca99..0000000 --- a/poetry.lock +++ /dev/null @@ -1,1919 +0,0 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. - -[[package]] -name = "annotated-types" -version = "0.6.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -files = [ - {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, - {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} - -[[package]] -name = "anyio" -version = "4.2.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.8" -files = [ - {file = "anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee"}, - {file = "anyio-4.2.0.tar.gz", hash = "sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f"}, -] - -[package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} - -[package.extras] -doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (>=0.23)"] - -[[package]] -name = "astroid" -version = "3.0.3" -description = "An abstract syntax tree for Python with inference support." -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "astroid-3.0.3-py3-none-any.whl", hash = "sha256:92fcf218b89f449cdf9f7b39a269f8d5d617b27be68434912e11e79203963a17"}, - {file = "astroid-3.0.3.tar.gz", hash = "sha256:4148645659b08b70d72460ed1921158027a9e53ae8b7234149b1400eddacbb93"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} - -[[package]] -name = "attrs" -version = "23.2.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.7" -files = [ - {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, - {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, -] - -[package.extras] -cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[tests]", "pre-commit"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] -tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] -tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] - -[[package]] -name = "babel" -version = "2.14.0" -description = "Internationalization utilities" -optional = false -python-versions = ">=3.7" -files = [ - {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, - {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, -] - -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - -[package.extras] -dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] - -[[package]] -name = "bandit" -version = "1.7.7" -description = "Security oriented static analyser for python code." -optional = false -python-versions = ">=3.8" -files = [ - {file = "bandit-1.7.7-py3-none-any.whl", hash = "sha256:17e60786a7ea3c9ec84569fd5aee09936d116cb0cb43151023258340dbffb7ed"}, - {file = "bandit-1.7.7.tar.gz", hash = "sha256:527906bec6088cb499aae31bc962864b4e77569e9d529ee51df3a93b4b8ab28a"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} -PyYAML = ">=5.3.1" -rich = "*" -stevedore = ">=1.20.0" - -[package.extras] -baseline = ["GitPython (>=3.1.30)"] -test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] -toml = ["tomli (>=1.1.0)"] -yaml = ["PyYAML"] - -[[package]] -name = "cachetools" -version = "5.3.2" -description = "Extensible memoizing collections and decorators" -optional = false -python-versions = ">=3.7" -files = [ - {file = "cachetools-5.3.2-py3-none-any.whl", hash = "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1"}, - {file = "cachetools-5.3.2.tar.gz", hash = "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2"}, -] - -[[package]] -name = "certifi" -version = "2024.2.2" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, -] - -[[package]] -name = "chardet" -version = "5.2.0" -description = "Universal encoding detector for Python 3" -optional = false -python-versions = ">=3.7" -files = [ - {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, - {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.3.2" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, -] - -[[package]] -name = "click" -version = "8.1.7" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coverage" -version = "7.4.1" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "coverage-7.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7"}, - {file = "coverage-7.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61"}, - {file = "coverage-7.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee"}, - {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25"}, - {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19"}, - {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630"}, - {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c"}, - {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b"}, - {file = "coverage-7.4.1-cp310-cp310-win32.whl", hash = "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016"}, - {file = "coverage-7.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018"}, - {file = "coverage-7.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295"}, - {file = "coverage-7.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c"}, - {file = "coverage-7.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676"}, - {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd"}, - {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011"}, - {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74"}, - {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1"}, - {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6"}, - {file = "coverage-7.4.1-cp311-cp311-win32.whl", hash = "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5"}, - {file = "coverage-7.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968"}, - {file = "coverage-7.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581"}, - {file = "coverage-7.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6"}, - {file = "coverage-7.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66"}, - {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156"}, - {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3"}, - {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1"}, - {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1"}, - {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc"}, - {file = "coverage-7.4.1-cp312-cp312-win32.whl", hash = "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74"}, - {file = "coverage-7.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448"}, - {file = "coverage-7.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218"}, - {file = "coverage-7.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45"}, - {file = "coverage-7.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d"}, - {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06"}, - {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766"}, - {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75"}, - {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60"}, - {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad"}, - {file = "coverage-7.4.1-cp38-cp38-win32.whl", hash = "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042"}, - {file = "coverage-7.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d"}, - {file = "coverage-7.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54"}, - {file = "coverage-7.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70"}, - {file = "coverage-7.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628"}, - {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950"}, - {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1"}, - {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7"}, - {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756"}, - {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35"}, - {file = "coverage-7.4.1-cp39-cp39-win32.whl", hash = "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c"}, - {file = "coverage-7.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a"}, - {file = "coverage-7.4.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166"}, - {file = "coverage-7.4.1.tar.gz", hash = "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli"] - -[[package]] -name = "dill" -version = "0.3.8" -description = "serialize all of Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, - {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, -] - -[package.extras] -graph = ["objgraph (>=1.7.2)"] -profile = ["gprof2dot (>=2022.7.29)"] - -[[package]] -name = "distlib" -version = "0.3.8" -description = "Distribution utilities" -optional = false -python-versions = "*" -files = [ - {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, - {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.0" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "fastapi" -version = "0.109.2" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -optional = false -python-versions = ">=3.8" -files = [ - {file = "fastapi-0.109.2-py3-none-any.whl", hash = "sha256:2c9bab24667293b501cad8dd388c05240c850b58ec5876ee3283c47d6e1e3a4d"}, - {file = "fastapi-0.109.2.tar.gz", hash = "sha256:f3817eac96fe4f65a2ebb4baa000f394e55f5fccdaf7f75250804bc58f354f73"}, -] - -[package.dependencies] -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.36.3,<0.37.0" -typing-extensions = ">=4.8.0" - -[package.extras] -all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] - -[[package]] -name = "filelock" -version = "3.13.1" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.8" -files = [ - {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, - {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, -] - -[package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] -typing = ["typing-extensions (>=4.8)"] - -[[package]] -name = "frozendict" -version = "2.4.0" -description = "A simple immutable dictionary" -optional = false -python-versions = ">=3.6" -files = [ - {file = "frozendict-2.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:475c65202a6f5421df8cacb8a2f29c5087134a0542b0540ae95fbf4db7af2ff9"}, - {file = "frozendict-2.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2607e82efdd2c277224a58bda3994d4cd48e49eff7fa31e404cf3066e8dbfeae"}, - {file = "frozendict-2.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fd4583194baabe100c135883017da76259a315d34e303eddf198541b7e02e44"}, - {file = "frozendict-2.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efca7281184b54f7abab6980cf25837b709f72ced62791f62dabcd7b184d958a"}, - {file = "frozendict-2.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fc4cba1ced988ce9020dfcaae6fe3f5521eebc00c5772b511aaf691b0be91e6"}, - {file = "frozendict-2.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8fab616e7c0fea2ac928f107c740bd9ba516fc083adfcd1c391d6bfc9164403d"}, - {file = "frozendict-2.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:09ba8ee37d260adde311b8eb4cd12bf27f64071242f736757ae6a11d331eb860"}, - {file = "frozendict-2.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:0615ed71570eec3cc96df063930ea6e563211efeeac86e3f3cc8bdfc9c9bfab7"}, - {file = "frozendict-2.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc754117a7d60ba8e55b3c39abd67f37fbc05dd63cdcb03d1717a382fe0a3421"}, - {file = "frozendict-2.4.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2804ea4bd2179bb33b99483cc8d69246630cc00632b9affe2914e8666f1cc7e5"}, - {file = "frozendict-2.4.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd4700c3f0aebdc8f4375c35590135794b1dbf2aca132f4756b584fa9910af2d"}, - {file = "frozendict-2.4.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:da4406d95c340e0b1cc43a3858fac729f52689325bcf61a9182eb94aff7451dc"}, - {file = "frozendict-2.4.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:1875e7b70a5724bf964354da8fd542240d2cead0d80053ac96bf4494ce3517fa"}, - {file = "frozendict-2.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a60f353496637ca21396289a7d969af1eb4ec4d11a7c37a0e7f25fc1761a0c97"}, - {file = "frozendict-2.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b666f9c6c8a9e794d2713a944b10a65480ff459579d75b5f686c75031c2c2dfc"}, - {file = "frozendict-2.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d81fb396ea81fcba3b3dde4a4b51adcb74ff31632014fbfd030f8acd5a7292"}, - {file = "frozendict-2.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4925c8e82d2bd23d45996cd0827668a52b9c51103897c98ce409a763d0c00c61"}, - {file = "frozendict-2.4.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aa86325da6a6071284b4ed3d9d2cd9db068560aebad503b658d6a889a0575683"}, - {file = "frozendict-2.4.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5bb5b62d4e2bce12e91800496d94de41bec8f16e4d8a7b16e8f263676ae2031a"}, - {file = "frozendict-2.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3909df909516cfd7bcefd9a3003948970a12a50c5648d8bbddafcef171f2117f"}, - {file = "frozendict-2.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:204f2c5c10fc018d1ba8ccc67758aa83fe769c782547bd26dc250317a7ccba71"}, - {file = "frozendict-2.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d8d1d269874c94b1ed2b6667e5e43dcf4541838019b1caa4c48f848ac73634df"}, - {file = "frozendict-2.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:809f1cffb602cf06e5186c69c0e3b74bec7a3684593145331f9aa2a65b5ba3b7"}, - {file = "frozendict-2.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b017cba5f73869b04c2977139ad08e57a7480de1e384c34193939698119baa1d"}, - {file = "frozendict-2.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0b75e5e231621dedaef88334997e79fbd137dd89895543d3862fe0220fc3572c"}, - {file = "frozendict-2.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:df3819a5d48ab3aae1548e62093d0111ad7c3b62ff9392421b7bbf149c08b629"}, - {file = "frozendict-2.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:42a9b33ccf9d417b22146e59803c53d5c39d7d9151d2df8df59c235f6a1a5ed7"}, - {file = "frozendict-2.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3f51bfa64e0c4a6608e3f2878bab1211a6b3b197de6fa57151bbe73f1184457"}, - {file = "frozendict-2.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a1d232f092dc686e6ef23d436bde30f82c018f31cef1b89b31caef03814b1617"}, - {file = "frozendict-2.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e530658134e88607ff8c2c8934a07b2bb5e9fffab5045f127746f6542c6c77e"}, - {file = "frozendict-2.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23a52bbea30c9e35b89291273944393770fb031e522a172e3aff19b62cc50047"}, - {file = "frozendict-2.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f91acaff475d0ef0d3436b805c9b91fc627a6a8a281771a24f7ab7f458a0b34f"}, - {file = "frozendict-2.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:08d9c7c1aa92b94538b3a79c43999f999012e174588435f197794d5e5a80e0f5"}, - {file = "frozendict-2.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:05c5a77957ecba4286c7ab33861a8f4f2badc7ea86fc82b834fb360d3aa4c108"}, - {file = "frozendict-2.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:c8af8a6a39e0050d3f3193cda56c42b43534a9b3995c44241bb9527e3c3fd451"}, - {file = "frozendict-2.4.0.tar.gz", hash = "sha256:c26758198e403337933a92b01f417a8240c954f553e1d4b5e0f8e39d9c8e3f0a"}, -] - -[[package]] -name = "ghp-import" -version = "2.1.0" -description = "Copy your docs directly to the gh-pages branch." -optional = false -python-versions = "*" -files = [ - {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, - {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, -] - -[package.dependencies] -python-dateutil = ">=2.8.1" - -[package.extras] -dev = ["flake8", "markdown", "twine", "wheel"] - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.7" -files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, -] - -[[package]] -name = "httpcore" -version = "1.0.2" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"}, - {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.13,<0.15" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<0.23.0)"] - -[[package]] -name = "httpx" -version = "0.26.0" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"}, - {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" -sniffio = "*" - -[package.extras] -brotli = ["brotli", "brotlicffi"] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] - -[[package]] -name = "idna" -version = "3.6" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.5" -files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, -] - -[[package]] -name = "importlib-metadata" -version = "7.0.1" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, - {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, -] - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] - -[[package]] -name = "importlib-resources" -version = "6.1.1" -description = "Read resources from Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "importlib_resources-6.1.1-py3-none-any.whl", hash = "sha256:e8bf90d8213b486f428c9c39714b920041cb02c184686a3dee24905aaa8105d6"}, - {file = "importlib_resources-6.1.1.tar.gz", hash = "sha256:3893a00122eafde6894c59914446a512f728a0c1a45f9bb9b63721b6bacf0b4a"}, -] - -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff", "zipp (>=3.17)"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "isort" -version = "5.13.2" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, - {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, -] - -[package.extras] -colors = ["colorama (>=0.4.6)"] - -[[package]] -name = "jinja2" -version = "3.1.3" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -files = [ - {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, - {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "jsonref" -version = "1.1.0" -description = "jsonref is a library for automatic dereferencing of JSON Reference objects for Python." -optional = false -python-versions = ">=3.7" -files = [ - {file = "jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9"}, - {file = "jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552"}, -] - -[[package]] -name = "jsonschema" -version = "4.21.1" -description = "An implementation of JSON Schema validation for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jsonschema-4.21.1-py3-none-any.whl", hash = "sha256:7996507afae316306f9e2290407761157c6f78002dcf7419acb99822143d1c6f"}, - {file = "jsonschema-4.21.1.tar.gz", hash = "sha256:85727c00279f5fa6bedbe6238d2aa6403bedd8b4864ab11207d07df3cc1b2ee5"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} -jsonschema-specifications = ">=2023.03.6" -pkgutil-resolve-name = {version = ">=1.3.10", markers = "python_version < \"3.9\""} -referencing = ">=0.28.4" -rpds-py = ">=0.7.1" - -[package.extras] -format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=1.11)"] - -[[package]] -name = "jsonschema-specifications" -version = "2023.12.1" -description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" -optional = false -python-versions = ">=3.8" -files = [ - {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, - {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, -] - -[package.dependencies] -importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} -referencing = ">=0.31.0" - -[[package]] -name = "markdown" -version = "3.5.2" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.8" -files = [ - {file = "Markdown-3.5.2-py3-none-any.whl", hash = "sha256:d43323865d89fc0cb9b20c75fc8ad313af307cc087e84b657d9eec768eddeadd"}, - {file = "Markdown-3.5.2.tar.gz", hash = "sha256:e1ac7b3dc550ee80e602e71c1d168002f062e49f1b11e26a36264dafd4df2ef8"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - -[package.extras] -docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] -testing = ["coverage", "pyyaml"] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.8" -files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "markupsafe" -version = "2.1.5" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.7" -files = [ - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, - {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, -] - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mergedeep" -version = "1.3.4" -description = "A deep merge function for 🐍." -optional = false -python-versions = ">=3.6" -files = [ - {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, - {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, -] - -[[package]] -name = "mkdocs" -version = "1.5.3" -description = "Project documentation with Markdown." -optional = false -python-versions = ">=3.7" -files = [ - {file = "mkdocs-1.5.3-py3-none-any.whl", hash = "sha256:3b3a78e736b31158d64dbb2f8ba29bd46a379d0c6e324c2246c3bc3d2189cfc1"}, - {file = "mkdocs-1.5.3.tar.gz", hash = "sha256:eb7c99214dcb945313ba30426c2451b735992c73c2e10838f76d09e39ff4d0e2"}, -] - -[package.dependencies] -click = ">=7.0" -colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} -ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} -jinja2 = ">=2.11.1" -markdown = ">=3.2.1" -markupsafe = ">=2.0.1" -mergedeep = ">=1.3.4" -packaging = ">=20.5" -pathspec = ">=0.11.1" -platformdirs = ">=2.2.0" -pyyaml = ">=5.1" -pyyaml-env-tag = ">=0.1" -watchdog = ">=2.0" - -[package.extras] -i18n = ["babel (>=2.9.0)"] -min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.3)", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pathspec (==0.11.1)", "platformdirs (==2.2.0)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10)", "watchdog (==2.0)"] - -[[package]] -name = "mkdocs-material" -version = "9.5.9" -description = "Documentation that simply works" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mkdocs_material-9.5.9-py3-none-any.whl", hash = "sha256:a5d62b73b3b74349e45472bfadc129c871dd2d4add68d84819580597b2f50d5d"}, - {file = "mkdocs_material-9.5.9.tar.gz", hash = "sha256:635df543c01c25c412d6c22991872267723737d5a2f062490f33b2da1c013c6d"}, -] - -[package.dependencies] -babel = ">=2.10,<3.0" -colorama = ">=0.4,<1.0" -jinja2 = ">=3.0,<4.0" -markdown = ">=3.2,<4.0" -mkdocs = ">=1.5.3,<1.6.0" -mkdocs-material-extensions = ">=1.3,<2.0" -paginate = ">=0.5,<1.0" -pygments = ">=2.16,<3.0" -pymdown-extensions = ">=10.2,<11.0" -regex = ">=2022.4" -requests = ">=2.26,<3.0" - -[package.extras] -git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] -imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"] -recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -description = "Extension pack for Python Markdown and MkDocs Material." -optional = false -python-versions = ">=3.8" -files = [ - {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, - {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, -] - -[[package]] -name = "mypy" -version = "1.8.0" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, - {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, - {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, - {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, - {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, - {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, - {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, - {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, - {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, - {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, - {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, - {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, - {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, - {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, - {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, - {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, - {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, - {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, - {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, -] - -[package.dependencies] -mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.1.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - -[[package]] -name = "packaging" -version = "23.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, -] - -[[package]] -name = "paginate" -version = "0.5.6" -description = "Divides large result sets into pages for easier browsing" -optional = false -python-versions = "*" -files = [ - {file = "paginate-0.5.6.tar.gz", hash = "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d"}, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - -[[package]] -name = "pbr" -version = "6.0.0" -description = "Python Build Reasonableness" -optional = false -python-versions = ">=2.6" -files = [ - {file = "pbr-6.0.0-py2.py3-none-any.whl", hash = "sha256:4a7317d5e3b17a3dccb6a8cfe67dab65b20551404c52c8ed41279fa4f0cb4cda"}, - {file = "pbr-6.0.0.tar.gz", hash = "sha256:d1377122a5a00e2f940ee482999518efe16d745d423a670c27773dfbc3c9a7d9"}, -] - -[[package]] -name = "pkgutil-resolve-name" -version = "1.3.10" -description = "Resolve a name to an object." -optional = false -python-versions = ">=3.6" -files = [ - {file = "pkgutil_resolve_name-1.3.10-py3-none-any.whl", hash = "sha256:ca27cc078d25c5ad71a9de0a7a330146c4e014c2462d9af19c6b828280649c5e"}, - {file = "pkgutil_resolve_name-1.3.10.tar.gz", hash = "sha256:357d6c9e6a755653cfd78893817c0853af365dd51ec97f3d358a819373bbd174"}, -] - -[[package]] -name = "platformdirs" -version = "4.2.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -optional = false -python-versions = ">=3.8" -files = [ - {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, - {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, -] - -[package.extras] -docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] - -[[package]] -name = "pluggy" -version = "1.4.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, - {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pydantic" -version = "2.6.1" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic-2.6.1-py3-none-any.whl", hash = "sha256:0b6a909df3192245cb736509a92ff69e4fef76116feffec68e93a567347bae6f"}, - {file = "pydantic-2.6.1.tar.gz", hash = "sha256:4fd5c182a2488dc63e6d32737ff19937888001e2a6d86e94b3f233104a5d1fa9"}, -] - -[package.dependencies] -annotated-types = ">=0.4.0" -pydantic-core = "2.16.2" -typing-extensions = ">=4.6.1" - -[package.extras] -email = ["email-validator (>=2.0.0)"] - -[[package]] -name = "pydantic-core" -version = "2.16.2" -description = "" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_core-2.16.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3fab4e75b8c525a4776e7630b9ee48aea50107fea6ca9f593c98da3f4d11bf7c"}, - {file = "pydantic_core-2.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8bde5b48c65b8e807409e6f20baee5d2cd880e0fad00b1a811ebc43e39a00ab2"}, - {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2924b89b16420712e9bb8192396026a8fbd6d8726224f918353ac19c4c043d2a"}, - {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16aa02e7a0f539098e215fc193c8926c897175d64c7926d00a36188917717a05"}, - {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:936a787f83db1f2115ee829dd615c4f684ee48ac4de5779ab4300994d8af325b"}, - {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:459d6be6134ce3b38e0ef76f8a672924460c455d45f1ad8fdade36796df1ddc8"}, - {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9ee4febb249c591d07b2d4dd36ebcad0ccd128962aaa1801508320896575ef"}, - {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40a0bd0bed96dae5712dab2aba7d334a6c67cbcac2ddfca7dbcc4a8176445990"}, - {file = "pydantic_core-2.16.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:870dbfa94de9b8866b37b867a2cb37a60c401d9deb4a9ea392abf11a1f98037b"}, - {file = "pydantic_core-2.16.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:308974fdf98046db28440eb3377abba274808bf66262e042c412eb2adf852731"}, - {file = "pydantic_core-2.16.2-cp310-none-win32.whl", hash = "sha256:a477932664d9611d7a0816cc3c0eb1f8856f8a42435488280dfbf4395e141485"}, - {file = "pydantic_core-2.16.2-cp310-none-win_amd64.whl", hash = "sha256:8f9142a6ed83d90c94a3efd7af8873bf7cefed2d3d44387bf848888482e2d25f"}, - {file = "pydantic_core-2.16.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:406fac1d09edc613020ce9cf3f2ccf1a1b2f57ab00552b4c18e3d5276c67eb11"}, - {file = "pydantic_core-2.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ce232a6170dd6532096cadbf6185271e4e8c70fc9217ebe105923ac105da9978"}, - {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a90fec23b4b05a09ad988e7a4f4e081711a90eb2a55b9c984d8b74597599180f"}, - {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8aafeedb6597a163a9c9727d8a8bd363a93277701b7bfd2749fbefee2396469e"}, - {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9957433c3a1b67bdd4c63717eaf174ebb749510d5ea612cd4e83f2d9142f3fc8"}, - {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0d7a9165167269758145756db43a133608a531b1e5bb6a626b9ee24bc38a8f7"}, - {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dffaf740fe2e147fedcb6b561353a16243e654f7fe8e701b1b9db148242e1272"}, - {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8ed79883b4328b7f0bd142733d99c8e6b22703e908ec63d930b06be3a0e7113"}, - {file = "pydantic_core-2.16.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cf903310a34e14651c9de056fcc12ce090560864d5a2bb0174b971685684e1d8"}, - {file = "pydantic_core-2.16.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:46b0d5520dbcafea9a8645a8164658777686c5c524d381d983317d29687cce97"}, - {file = "pydantic_core-2.16.2-cp311-none-win32.whl", hash = "sha256:70651ff6e663428cea902dac297066d5c6e5423fda345a4ca62430575364d62b"}, - {file = "pydantic_core-2.16.2-cp311-none-win_amd64.whl", hash = "sha256:98dc6f4f2095fc7ad277782a7c2c88296badcad92316b5a6e530930b1d475ebc"}, - {file = "pydantic_core-2.16.2-cp311-none-win_arm64.whl", hash = "sha256:ef6113cd31411eaf9b39fc5a8848e71c72656fd418882488598758b2c8c6dfa0"}, - {file = "pydantic_core-2.16.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:88646cae28eb1dd5cd1e09605680c2b043b64d7481cdad7f5003ebef401a3039"}, - {file = "pydantic_core-2.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7b883af50eaa6bb3299780651e5be921e88050ccf00e3e583b1e92020333304b"}, - {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bf26c2e2ea59d32807081ad51968133af3025c4ba5753e6a794683d2c91bf6e"}, - {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99af961d72ac731aae2a1b55ccbdae0733d816f8bfb97b41909e143de735f522"}, - {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02906e7306cb8c5901a1feb61f9ab5e5c690dbbeaa04d84c1b9ae2a01ebe9379"}, - {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5362d099c244a2d2f9659fb3c9db7c735f0004765bbe06b99be69fbd87c3f15"}, - {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ac426704840877a285d03a445e162eb258924f014e2f074e209d9b4ff7bf380"}, - {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b94cbda27267423411c928208e89adddf2ea5dd5f74b9528513f0358bba019cb"}, - {file = "pydantic_core-2.16.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6db58c22ac6c81aeac33912fb1af0e930bc9774166cdd56eade913d5f2fff35e"}, - {file = "pydantic_core-2.16.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:396fdf88b1b503c9c59c84a08b6833ec0c3b5ad1a83230252a9e17b7dfb4cffc"}, - {file = "pydantic_core-2.16.2-cp312-none-win32.whl", hash = "sha256:7c31669e0c8cc68400ef0c730c3a1e11317ba76b892deeefaf52dcb41d56ed5d"}, - {file = "pydantic_core-2.16.2-cp312-none-win_amd64.whl", hash = "sha256:a3b7352b48fbc8b446b75f3069124e87f599d25afb8baa96a550256c031bb890"}, - {file = "pydantic_core-2.16.2-cp312-none-win_arm64.whl", hash = "sha256:a9e523474998fb33f7c1a4d55f5504c908d57add624599e095c20fa575b8d943"}, - {file = "pydantic_core-2.16.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:ae34418b6b389d601b31153b84dce480351a352e0bb763684a1b993d6be30f17"}, - {file = "pydantic_core-2.16.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:732bd062c9e5d9582a30e8751461c1917dd1ccbdd6cafb032f02c86b20d2e7ec"}, - {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b52776a2e3230f4854907a1e0946eec04d41b1fc64069ee774876bbe0eab55"}, - {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef551c053692b1e39e3f7950ce2296536728871110e7d75c4e7753fb30ca87f4"}, - {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ebb892ed8599b23fa8f1799e13a12c87a97a6c9d0f497525ce9858564c4575a4"}, - {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa6c8c582036275997a733427b88031a32ffa5dfc3124dc25a730658c47a572f"}, - {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4ba0884a91f1aecce75202473ab138724aa4fb26d7707f2e1fa6c3e68c84fbf"}, - {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7924e54f7ce5d253d6160090ddc6df25ed2feea25bfb3339b424a9dd591688bc"}, - {file = "pydantic_core-2.16.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69a7b96b59322a81c2203be537957313b07dd333105b73db0b69212c7d867b4b"}, - {file = "pydantic_core-2.16.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7e6231aa5bdacda78e96ad7b07d0c312f34ba35d717115f4b4bff6cb87224f0f"}, - {file = "pydantic_core-2.16.2-cp38-none-win32.whl", hash = "sha256:41dac3b9fce187a25c6253ec79a3f9e2a7e761eb08690e90415069ea4a68ff7a"}, - {file = "pydantic_core-2.16.2-cp38-none-win_amd64.whl", hash = "sha256:f685dbc1fdadb1dcd5b5e51e0a378d4685a891b2ddaf8e2bba89bd3a7144e44a"}, - {file = "pydantic_core-2.16.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:55749f745ebf154c0d63d46c8c58594d8894b161928aa41adbb0709c1fe78b77"}, - {file = "pydantic_core-2.16.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b30b0dd58a4509c3bd7eefddf6338565c4905406aee0c6e4a5293841411a1286"}, - {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18de31781cdc7e7b28678df7c2d7882f9692ad060bc6ee3c94eb15a5d733f8f7"}, - {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5864b0242f74b9dd0b78fd39db1768bc3f00d1ffc14e596fd3e3f2ce43436a33"}, - {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8f9186ca45aee030dc8234118b9c0784ad91a0bb27fc4e7d9d6608a5e3d386c"}, - {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc6f6c9be0ab6da37bc77c2dda5f14b1d532d5dbef00311ee6e13357a418e646"}, - {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa057095f621dad24a1e906747179a69780ef45cc8f69e97463692adbcdae878"}, - {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ad84731a26bcfb299f9eab56c7932d46f9cad51c52768cace09e92a19e4cf55"}, - {file = "pydantic_core-2.16.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3b052c753c4babf2d1edc034c97851f867c87d6f3ea63a12e2700f159f5c41c3"}, - {file = "pydantic_core-2.16.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e0f686549e32ccdb02ae6f25eee40cc33900910085de6aa3790effd391ae10c2"}, - {file = "pydantic_core-2.16.2-cp39-none-win32.whl", hash = "sha256:7afb844041e707ac9ad9acad2188a90bffce2c770e6dc2318be0c9916aef1469"}, - {file = "pydantic_core-2.16.2-cp39-none-win_amd64.whl", hash = "sha256:9da90d393a8227d717c19f5397688a38635afec89f2e2d7af0df037f3249c39a"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f60f920691a620b03082692c378661947d09415743e437a7478c309eb0e4f82"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:47924039e785a04d4a4fa49455e51b4eb3422d6eaacfde9fc9abf8fdef164e8a"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6294e76b0380bb7a61eb8a39273c40b20beb35e8c87ee101062834ced19c545"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe56851c3f1d6f5384b3051c536cc81b3a93a73faf931f404fef95217cf1e10d"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9d776d30cde7e541b8180103c3f294ef7c1862fd45d81738d156d00551005784"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:72f7919af5de5ecfaf1eba47bf9a5d8aa089a3340277276e5636d16ee97614d7"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:4bfcbde6e06c56b30668a0c872d75a7ef3025dc3c1823a13cf29a0e9b33f67e8"}, - {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ff7c97eb7a29aba230389a2661edf2e9e06ce616c7e35aa764879b6894a44b25"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9b5f13857da99325dcabe1cc4e9e6a3d7b2e2c726248ba5dd4be3e8e4a0b6d0e"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a7e41e3ada4cca5f22b478c08e973c930e5e6c7ba3588fb8e35f2398cdcc1545"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60eb8ceaa40a41540b9acae6ae7c1f0a67d233c40dc4359c256ad2ad85bdf5e5"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7beec26729d496a12fd23cf8da9944ee338c8b8a17035a560b585c36fe81af20"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22c5f022799f3cd6741e24f0443ead92ef42be93ffda0d29b2597208c94c3753"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:eca58e319f4fd6df004762419612122b2c7e7d95ffafc37e890252f869f3fb2a"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed957db4c33bc99895f3a1672eca7e80e8cda8bd1e29a80536b4ec2153fa9804"}, - {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:459c0d338cc55d099798618f714b21b7ece17eb1a87879f2da20a3ff4c7628e2"}, - {file = "pydantic_core-2.16.2.tar.gz", hash = "sha256:0ba503850d8b8dcc18391f10de896ae51d37fe5fe43dbfb6a35c5c5cad271a06"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - -[[package]] -name = "pygments" -version = "2.17.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.7" -files = [ - {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, - {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, -] - -[package.extras] -plugins = ["importlib-metadata"] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pylint" -version = "3.0.3" -description = "python code static checker" -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "pylint-3.0.3-py3-none-any.whl", hash = "sha256:7a1585285aefc5165db81083c3e06363a27448f6b467b3b0f30dbd0ac1f73810"}, - {file = "pylint-3.0.3.tar.gz", hash = "sha256:58c2398b0301e049609a8429789ec6edf3aabe9b6c5fec916acd18639c16de8b"}, -] - -[package.dependencies] -astroid = ">=3.0.1,<=3.1.0-dev0" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -dill = [ - {version = ">=0.2", markers = "python_version < \"3.11\""}, - {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, - {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, -] -isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" -mccabe = ">=0.6,<0.8" -platformdirs = ">=2.2.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -tomlkit = ">=0.10.1" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} - -[package.extras] -spelling = ["pyenchant (>=3.2,<4.0)"] -testutils = ["gitpython (>3)"] - -[[package]] -name = "pylint-plugin-utils" -version = "0.8.2" -description = "Utilities and helpers for writing Pylint plugins" -optional = false -python-versions = ">=3.7,<4.0" -files = [ - {file = "pylint_plugin_utils-0.8.2-py3-none-any.whl", hash = "sha256:ae11664737aa2effbf26f973a9e0b6779ab7106ec0adc5fe104b0907ca04e507"}, - {file = "pylint_plugin_utils-0.8.2.tar.gz", hash = "sha256:d3cebf68a38ba3fba23a873809155562571386d4c1b03e5b4c4cc26c3eee93e4"}, -] - -[package.dependencies] -pylint = ">=1.7" - -[[package]] -name = "pylint-pydantic" -version = "0.3.2" -description = "A Pylint plugin to help Pylint understand the Pydantic" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pylint_pydantic-0.3.2-py3-none-any.whl", hash = "sha256:e5cec02370aa68ac8eff138e5d573b0ac049bab864e9a6c3a9057cf043440aa1"}, -] - -[package.dependencies] -pydantic = "<3.0" -pylint = ">2.0,<4.0" -pylint-plugin-utils = "*" - -[[package]] -name = "pymdown-extensions" -version = "10.7" -description = "Extension pack for Python Markdown." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pymdown_extensions-10.7-py3-none-any.whl", hash = "sha256:6ca215bc57bc12bf32b414887a68b810637d039124ed9b2e5bd3325cbb2c050c"}, - {file = "pymdown_extensions-10.7.tar.gz", hash = "sha256:c0d64d5cf62566f59e6b2b690a4095c931107c250a8c8e1351c1de5f6b036deb"}, -] - -[package.dependencies] -markdown = ">=3.5" -pyyaml = "*" - -[package.extras] -extra = ["pygments (>=2.12)"] - -[[package]] -name = "pyproject-api" -version = "1.6.1" -description = "API to interact with the python pyproject.toml based projects" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pyproject_api-1.6.1-py3-none-any.whl", hash = "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675"}, - {file = "pyproject_api-1.6.1.tar.gz", hash = "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538"}, -] - -[package.dependencies] -packaging = ">=23.1" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -docs = ["furo (>=2023.8.19)", "sphinx (<7.2)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "setuptools (>=68.1.2)", "wheel (>=0.41.2)"] - -[[package]] -name = "pytest" -version = "8.0.0" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-8.0.0-py3-none-any.whl", hash = "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6"}, - {file = "pytest-8.0.0.tar.gz", hash = "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.3.0,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} - -[package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-cov" -version = "4.1.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, - {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, -] - -[package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] - -[[package]] -name = "pytest-lazy-fixtures" -version = "1.0.5" -description = "Allows you to use fixtures in @pytest.mark.parametrize." -optional = false -python-versions = ">=3.8,<4.0" -files = [ - {file = "pytest_lazy_fixtures-1.0.5-py3-none-any.whl", hash = "sha256:802a7e13d1f9a29a124a989d927e950ed4c75292b09ba598fb0077edef28cc7a"}, - {file = "pytest_lazy_fixtures-1.0.5.tar.gz", hash = "sha256:066b253a94c249e6d9cdfad465e2503d2219139fb468d8f687243dfde39ab9cb"}, -] - -[package.dependencies] -pytest = ">=7" - -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "pytz" -version = "2024.1" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, - {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.1" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.6" -files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, -] - -[[package]] -name = "pyyaml-env-tag" -version = "0.1" -description = "A custom YAML tag for referencing environment variables in YAML files. " -optional = false -python-versions = ">=3.6" -files = [ - {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, - {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, -] - -[package.dependencies] -pyyaml = "*" - -[[package]] -name = "referencing" -version = "0.33.0" -description = "JSON Referencing + Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "referencing-0.33.0-py3-none-any.whl", hash = "sha256:39240f2ecc770258f28b642dd47fd74bc8b02484de54e1882b74b35ebd779bd5"}, - {file = "referencing-0.33.0.tar.gz", hash = "sha256:c775fedf74bc0f9189c2a3be1c12fd03e8c23f4d371dce795df44e06c5b412f7"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -rpds-py = ">=0.7.0" - -[[package]] -name = "regex" -version = "2023.12.25" -description = "Alternative regular expression module, to replace re." -optional = false -python-versions = ">=3.7" -files = [ - {file = "regex-2023.12.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0694219a1d54336fd0445ea382d49d36882415c0134ee1e8332afd1529f0baa5"}, - {file = "regex-2023.12.25-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b014333bd0217ad3d54c143de9d4b9a3ca1c5a29a6d0d554952ea071cff0f1f8"}, - {file = "regex-2023.12.25-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d865984b3f71f6d0af64d0d88f5733521698f6c16f445bb09ce746c92c97c586"}, - {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e0eabac536b4cc7f57a5f3d095bfa557860ab912f25965e08fe1545e2ed8b4c"}, - {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c25a8ad70e716f96e13a637802813f65d8a6760ef48672aa3502f4c24ea8b400"}, - {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9b6d73353f777630626f403b0652055ebfe8ff142a44ec2cf18ae470395766e"}, - {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9cc99d6946d750eb75827cb53c4371b8b0fe89c733a94b1573c9dd16ea6c9e4"}, - {file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88d1f7bef20c721359d8675f7d9f8e414ec5003d8f642fdfd8087777ff7f94b5"}, - {file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cb3fe77aec8f1995611f966d0c656fdce398317f850d0e6e7aebdfe61f40e1cd"}, - {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7aa47c2e9ea33a4a2a05f40fcd3ea36d73853a2aae7b4feab6fc85f8bf2c9704"}, - {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:df26481f0c7a3f8739fecb3e81bc9da3fcfae34d6c094563b9d4670b047312e1"}, - {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c40281f7d70baf6e0db0c2f7472b31609f5bc2748fe7275ea65a0b4601d9b392"}, - {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d94a1db462d5690ebf6ae86d11c5e420042b9898af5dcf278bd97d6bda065423"}, - {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ba1b30765a55acf15dce3f364e4928b80858fa8f979ad41f862358939bdd1f2f"}, - {file = "regex-2023.12.25-cp310-cp310-win32.whl", hash = "sha256:150c39f5b964e4d7dba46a7962a088fbc91f06e606f023ce57bb347a3b2d4630"}, - {file = "regex-2023.12.25-cp310-cp310-win_amd64.whl", hash = "sha256:09da66917262d9481c719599116c7dc0c321ffcec4b1f510c4f8a066f8768105"}, - {file = "regex-2023.12.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b9d811f72210fa9306aeb88385b8f8bcef0dfbf3873410413c00aa94c56c2b6"}, - {file = "regex-2023.12.25-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d902a43085a308cef32c0d3aea962524b725403fd9373dea18110904003bac97"}, - {file = "regex-2023.12.25-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d166eafc19f4718df38887b2bbe1467a4f74a9830e8605089ea7a30dd4da8887"}, - {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7ad32824b7f02bb3c9f80306d405a1d9b7bb89362d68b3c5a9be53836caebdb"}, - {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:636ba0a77de609d6510235b7f0e77ec494d2657108f777e8765efc060094c98c"}, - {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fda75704357805eb953a3ee15a2b240694a9a514548cd49b3c5124b4e2ad01b"}, - {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f72cbae7f6b01591f90814250e636065850c5926751af02bb48da94dfced7baa"}, - {file = "regex-2023.12.25-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2a0b1857f18b11e3b0e54ddfefc96af46b0896fb678c85f63fb8c37518b3e7"}, - {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7502534e55c7c36c0978c91ba6f61703faf7ce733715ca48f499d3dbbd7657e0"}, - {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e8c7e08bb566de4faaf11984af13f6bcf6a08f327b13631d41d62592681d24fe"}, - {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:283fc8eed679758de38fe493b7d7d84a198b558942b03f017b1f94dda8efae80"}, - {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f44dd4d68697559d007462b0a3a1d9acd61d97072b71f6d1968daef26bc744bd"}, - {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:67d3ccfc590e5e7197750fcb3a2915b416a53e2de847a728cfa60141054123d4"}, - {file = "regex-2023.12.25-cp311-cp311-win32.whl", hash = "sha256:68191f80a9bad283432385961d9efe09d783bcd36ed35a60fb1ff3f1ec2efe87"}, - {file = "regex-2023.12.25-cp311-cp311-win_amd64.whl", hash = "sha256:7d2af3f6b8419661a0c421584cfe8aaec1c0e435ce7e47ee2a97e344b98f794f"}, - {file = "regex-2023.12.25-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8a0ccf52bb37d1a700375a6b395bff5dd15c50acb745f7db30415bae3c2b0715"}, - {file = "regex-2023.12.25-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c3c4a78615b7762740531c27cf46e2f388d8d727d0c0c739e72048beb26c8a9d"}, - {file = "regex-2023.12.25-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ad83e7545b4ab69216cef4cc47e344d19622e28aabec61574b20257c65466d6a"}, - {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7a635871143661feccce3979e1727c4e094f2bdfd3ec4b90dfd4f16f571a87a"}, - {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d498eea3f581fbe1b34b59c697512a8baef88212f92e4c7830fcc1499f5b45a5"}, - {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f7cd5754d02a56ae4ebb91b33461dc67be8e3e0153f593c509e21d219c5060"}, - {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51f4b32f793812714fd5307222a7f77e739b9bc566dc94a18126aba3b92b98a3"}, - {file = "regex-2023.12.25-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba99d8077424501b9616b43a2d208095746fb1284fc5ba490139651f971d39d9"}, - {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4bfc2b16e3ba8850e0e262467275dd4d62f0d045e0e9eda2bc65078c0110a11f"}, - {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8c2c19dae8a3eb0ea45a8448356ed561be843b13cbc34b840922ddf565498c1c"}, - {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:60080bb3d8617d96f0fb7e19796384cc2467447ef1c491694850ebd3670bc457"}, - {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b77e27b79448e34c2c51c09836033056a0547aa360c45eeeb67803da7b0eedaf"}, - {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:518440c991f514331f4850a63560321f833979d145d7d81186dbe2f19e27ae3d"}, - {file = "regex-2023.12.25-cp312-cp312-win32.whl", hash = "sha256:e2610e9406d3b0073636a3a2e80db05a02f0c3169b5632022b4e81c0364bcda5"}, - {file = "regex-2023.12.25-cp312-cp312-win_amd64.whl", hash = "sha256:cc37b9aeebab425f11f27e5e9e6cf580be7206c6582a64467a14dda211abc232"}, - {file = "regex-2023.12.25-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:da695d75ac97cb1cd725adac136d25ca687da4536154cdc2815f576e4da11c69"}, - {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d126361607b33c4eb7b36debc173bf25d7805847346dd4d99b5499e1fef52bc7"}, - {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4719bb05094d7d8563a450cf8738d2e1061420f79cfcc1fa7f0a44744c4d8f73"}, - {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5dd58946bce44b53b06d94aa95560d0b243eb2fe64227cba50017a8d8b3cd3e2"}, - {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22a86d9fff2009302c440b9d799ef2fe322416d2d58fc124b926aa89365ec482"}, - {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aae8101919e8aa05ecfe6322b278f41ce2994c4a430303c4cd163fef746e04f"}, - {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e692296c4cc2873967771345a876bcfc1c547e8dd695c6b89342488b0ea55cd8"}, - {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:263ef5cc10979837f243950637fffb06e8daed7f1ac1e39d5910fd29929e489a"}, - {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d6f7e255e5fa94642a0724e35406e6cb7001c09d476ab5fce002f652b36d0c39"}, - {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:88ad44e220e22b63b0f8f81f007e8abbb92874d8ced66f32571ef8beb0643b2b"}, - {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:3a17d3ede18f9cedcbe23d2daa8a2cd6f59fe2bf082c567e43083bba3fb00347"}, - {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d15b274f9e15b1a0b7a45d2ac86d1f634d983ca40d6b886721626c47a400bf39"}, - {file = "regex-2023.12.25-cp37-cp37m-win32.whl", hash = "sha256:ed19b3a05ae0c97dd8f75a5d8f21f7723a8c33bbc555da6bbe1f96c470139d3c"}, - {file = "regex-2023.12.25-cp37-cp37m-win_amd64.whl", hash = "sha256:a6d1047952c0b8104a1d371f88f4ab62e6275567d4458c1e26e9627ad489b445"}, - {file = "regex-2023.12.25-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b43523d7bc2abd757119dbfb38af91b5735eea45537ec6ec3a5ec3f9562a1c53"}, - {file = "regex-2023.12.25-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:efb2d82f33b2212898f1659fb1c2e9ac30493ac41e4d53123da374c3b5541e64"}, - {file = "regex-2023.12.25-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b7fca9205b59c1a3d5031f7e64ed627a1074730a51c2a80e97653e3e9fa0d415"}, - {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086dd15e9435b393ae06f96ab69ab2d333f5d65cbe65ca5a3ef0ec9564dfe770"}, - {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e81469f7d01efed9b53740aedd26085f20d49da65f9c1f41e822a33992cb1590"}, - {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34e4af5b27232f68042aa40a91c3b9bb4da0eeb31b7632e0091afc4310afe6cb"}, - {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9852b76ab558e45b20bf1893b59af64a28bd3820b0c2efc80e0a70a4a3ea51c1"}, - {file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff100b203092af77d1a5a7abe085b3506b7eaaf9abf65b73b7d6905b6cb76988"}, - {file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cc038b2d8b1470364b1888a98fd22d616fba2b6309c5b5f181ad4483e0017861"}, - {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:094ba386bb5c01e54e14434d4caabf6583334090865b23ef58e0424a6286d3dc"}, - {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5cd05d0f57846d8ba4b71d9c00f6f37d6b97d5e5ef8b3c3840426a475c8f70f4"}, - {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:9aa1a67bbf0f957bbe096375887b2505f5d8ae16bf04488e8b0f334c36e31360"}, - {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:98a2636994f943b871786c9e82bfe7883ecdaba2ef5df54e1450fa9869d1f756"}, - {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:37f8e93a81fc5e5bd8db7e10e62dc64261bcd88f8d7e6640aaebe9bc180d9ce2"}, - {file = "regex-2023.12.25-cp38-cp38-win32.whl", hash = "sha256:d78bd484930c1da2b9679290a41cdb25cc127d783768a0369d6b449e72f88beb"}, - {file = "regex-2023.12.25-cp38-cp38-win_amd64.whl", hash = "sha256:b521dcecebc5b978b447f0f69b5b7f3840eac454862270406a39837ffae4e697"}, - {file = "regex-2023.12.25-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f7bc09bc9c29ebead055bcba136a67378f03d66bf359e87d0f7c759d6d4ffa31"}, - {file = "regex-2023.12.25-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e14b73607d6231f3cc4622809c196b540a6a44e903bcfad940779c80dffa7be7"}, - {file = "regex-2023.12.25-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9eda5f7a50141291beda3edd00abc2d4a5b16c29c92daf8d5bd76934150f3edc"}, - {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc6bb9aa69aacf0f6032c307da718f61a40cf970849e471254e0e91c56ffca95"}, - {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:298dc6354d414bc921581be85695d18912bea163a8b23cac9a2562bbcd5088b1"}, - {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f4e475a80ecbd15896a976aa0b386c5525d0ed34d5c600b6d3ebac0a67c7ddf"}, - {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:531ac6cf22b53e0696f8e1d56ce2396311254eb806111ddd3922c9d937151dae"}, - {file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22f3470f7524b6da61e2020672df2f3063676aff444db1daa283c2ea4ed259d6"}, - {file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:89723d2112697feaa320c9d351e5f5e7b841e83f8b143dba8e2d2b5f04e10923"}, - {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0ecf44ddf9171cd7566ef1768047f6e66975788258b1c6c6ca78098b95cf9a3d"}, - {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:905466ad1702ed4acfd67a902af50b8db1feeb9781436372261808df7a2a7bca"}, - {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:4558410b7a5607a645e9804a3e9dd509af12fb72b9825b13791a37cd417d73a5"}, - {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:7e316026cc1095f2a3e8cc012822c99f413b702eaa2ca5408a513609488cb62f"}, - {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3b1de218d5375cd6ac4b5493e0b9f3df2be331e86520f23382f216c137913d20"}, - {file = "regex-2023.12.25-cp39-cp39-win32.whl", hash = "sha256:11a963f8e25ab5c61348d090bf1b07f1953929c13bd2309a0662e9ff680763c9"}, - {file = "regex-2023.12.25-cp39-cp39-win_amd64.whl", hash = "sha256:e693e233ac92ba83a87024e1d32b5f9ab15ca55ddd916d878146f4e3406b5c91"}, - {file = "regex-2023.12.25.tar.gz", hash = "sha256:29171aa128da69afdf4bde412d5bedc335f2ca8fcfe4489038577d05f16181e5"}, -] - -[[package]] -name = "requests" -version = "2.31.0" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.7" -files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "rich" -version = "13.7.0" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "rich-13.7.0-py3-none-any.whl", hash = "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235"}, - {file = "rich-13.7.0.tar.gz", hash = "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "rpds-py" -version = "0.17.1" -description = "Python bindings to Rust's persistent data structures (rpds)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "rpds_py-0.17.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4128980a14ed805e1b91a7ed551250282a8ddf8201a4e9f8f5b7e6225f54170d"}, - {file = "rpds_py-0.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ff1dcb8e8bc2261a088821b2595ef031c91d499a0c1b031c152d43fe0a6ecec8"}, - {file = "rpds_py-0.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d65e6b4f1443048eb7e833c2accb4fa7ee67cc7d54f31b4f0555b474758bee55"}, - {file = "rpds_py-0.17.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a71169d505af63bb4d20d23a8fbd4c6ce272e7bce6cc31f617152aa784436f29"}, - {file = "rpds_py-0.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:436474f17733c7dca0fbf096d36ae65277e8645039df12a0fa52445ca494729d"}, - {file = "rpds_py-0.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10162fe3f5f47c37ebf6d8ff5a2368508fe22007e3077bf25b9c7d803454d921"}, - {file = "rpds_py-0.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:720215373a280f78a1814becb1312d4e4d1077b1202a56d2b0815e95ccb99ce9"}, - {file = "rpds_py-0.17.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:70fcc6c2906cfa5c6a552ba7ae2ce64b6c32f437d8f3f8eea49925b278a61453"}, - {file = "rpds_py-0.17.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91e5a8200e65aaac342a791272c564dffcf1281abd635d304d6c4e6b495f29dc"}, - {file = "rpds_py-0.17.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:99f567dae93e10be2daaa896e07513dd4bf9c2ecf0576e0533ac36ba3b1d5394"}, - {file = "rpds_py-0.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24e4900a6643f87058a27320f81336d527ccfe503984528edde4bb660c8c8d59"}, - {file = "rpds_py-0.17.1-cp310-none-win32.whl", hash = "sha256:0bfb09bf41fe7c51413f563373e5f537eaa653d7adc4830399d4e9bdc199959d"}, - {file = "rpds_py-0.17.1-cp310-none-win_amd64.whl", hash = "sha256:20de7b7179e2031a04042e85dc463a93a82bc177eeba5ddd13ff746325558aa6"}, - {file = "rpds_py-0.17.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:65dcf105c1943cba45d19207ef51b8bc46d232a381e94dd38719d52d3980015b"}, - {file = "rpds_py-0.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:01f58a7306b64e0a4fe042047dd2b7d411ee82e54240284bab63e325762c1147"}, - {file = "rpds_py-0.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:071bc28c589b86bc6351a339114fb7a029f5cddbaca34103aa573eba7b482382"}, - {file = "rpds_py-0.17.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ae35e8e6801c5ab071b992cb2da958eee76340e6926ec693b5ff7d6381441745"}, - {file = "rpds_py-0.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149c5cd24f729e3567b56e1795f74577aa3126c14c11e457bec1b1c90d212e38"}, - {file = "rpds_py-0.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e796051f2070f47230c745d0a77a91088fbee2cc0502e9b796b9c6471983718c"}, - {file = "rpds_py-0.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e820ee1004327609b28db8307acc27f5f2e9a0b185b2064c5f23e815f248f8"}, - {file = "rpds_py-0.17.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1957a2ab607f9added64478a6982742eb29f109d89d065fa44e01691a20fc20a"}, - {file = "rpds_py-0.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8587fd64c2a91c33cdc39d0cebdaf30e79491cc029a37fcd458ba863f8815383"}, - {file = "rpds_py-0.17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4dc889a9d8a34758d0fcc9ac86adb97bab3fb7f0c4d29794357eb147536483fd"}, - {file = "rpds_py-0.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2953937f83820376b5979318840f3ee47477d94c17b940fe31d9458d79ae7eea"}, - {file = "rpds_py-0.17.1-cp311-none-win32.whl", hash = "sha256:1bfcad3109c1e5ba3cbe2f421614e70439f72897515a96c462ea657261b96518"}, - {file = "rpds_py-0.17.1-cp311-none-win_amd64.whl", hash = "sha256:99da0a4686ada4ed0f778120a0ea8d066de1a0a92ab0d13ae68492a437db78bf"}, - {file = "rpds_py-0.17.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1dc29db3900cb1bb40353772417800f29c3d078dbc8024fd64655a04ee3c4bdf"}, - {file = "rpds_py-0.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82ada4a8ed9e82e443fcef87e22a3eed3654dd3adf6e3b3a0deb70f03e86142a"}, - {file = "rpds_py-0.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d36b2b59e8cc6e576f8f7b671e32f2ff43153f0ad6d0201250a7c07f25d570e"}, - {file = "rpds_py-0.17.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3677fcca7fb728c86a78660c7fb1b07b69b281964673f486ae72860e13f512ad"}, - {file = "rpds_py-0.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:516fb8c77805159e97a689e2f1c80655c7658f5af601c34ffdb916605598cda2"}, - {file = "rpds_py-0.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df3b6f45ba4515632c5064e35ca7f31d51d13d1479673185ba8f9fefbbed58b9"}, - {file = "rpds_py-0.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a967dd6afda7715d911c25a6ba1517975acd8d1092b2f326718725461a3d33f9"}, - {file = "rpds_py-0.17.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dbbb95e6fc91ea3102505d111b327004d1c4ce98d56a4a02e82cd451f9f57140"}, - {file = "rpds_py-0.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02866e060219514940342a1f84303a1ef7a1dad0ac311792fbbe19b521b489d2"}, - {file = "rpds_py-0.17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2528ff96d09f12e638695f3a2e0c609c7b84c6df7c5ae9bfeb9252b6fa686253"}, - {file = "rpds_py-0.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd345a13ce06e94c753dab52f8e71e5252aec1e4f8022d24d56decd31e1b9b23"}, - {file = "rpds_py-0.17.1-cp312-none-win32.whl", hash = "sha256:2a792b2e1d3038daa83fa474d559acfd6dc1e3650ee93b2662ddc17dbff20ad1"}, - {file = "rpds_py-0.17.1-cp312-none-win_amd64.whl", hash = "sha256:292f7344a3301802e7c25c53792fae7d1593cb0e50964e7bcdcc5cf533d634e3"}, - {file = "rpds_py-0.17.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:8ffe53e1d8ef2520ebcf0c9fec15bb721da59e8ef283b6ff3079613b1e30513d"}, - {file = "rpds_py-0.17.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4341bd7579611cf50e7b20bb8c2e23512a3dc79de987a1f411cb458ab670eb90"}, - {file = "rpds_py-0.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f4eb548daf4836e3b2c662033bfbfc551db58d30fd8fe660314f86bf8510b93"}, - {file = "rpds_py-0.17.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b686f25377f9c006acbac63f61614416a6317133ab7fafe5de5f7dc8a06d42eb"}, - {file = "rpds_py-0.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e21b76075c01d65d0f0f34302b5a7457d95721d5e0667aea65e5bb3ab415c25"}, - {file = "rpds_py-0.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b86b21b348f7e5485fae740d845c65a880f5d1eda1e063bc59bef92d1f7d0c55"}, - {file = "rpds_py-0.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f175e95a197f6a4059b50757a3dca33b32b61691bdbd22c29e8a8d21d3914cae"}, - {file = "rpds_py-0.17.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1701fc54460ae2e5efc1dd6350eafd7a760f516df8dbe51d4a1c79d69472fbd4"}, - {file = "rpds_py-0.17.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9051e3d2af8f55b42061603e29e744724cb5f65b128a491446cc029b3e2ea896"}, - {file = "rpds_py-0.17.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:7450dbd659fed6dd41d1a7d47ed767e893ba402af8ae664c157c255ec6067fde"}, - {file = "rpds_py-0.17.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5a024fa96d541fd7edaa0e9d904601c6445e95a729a2900c5aec6555fe921ed6"}, - {file = "rpds_py-0.17.1-cp38-none-win32.whl", hash = "sha256:da1ead63368c04a9bded7904757dfcae01eba0e0f9bc41d3d7f57ebf1c04015a"}, - {file = "rpds_py-0.17.1-cp38-none-win_amd64.whl", hash = "sha256:841320e1841bb53fada91c9725e766bb25009cfd4144e92298db296fb6c894fb"}, - {file = "rpds_py-0.17.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:f6c43b6f97209e370124baf2bf40bb1e8edc25311a158867eb1c3a5d449ebc7a"}, - {file = "rpds_py-0.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7d63ec01fe7c76c2dbb7e972fece45acbb8836e72682bde138e7e039906e2c"}, - {file = "rpds_py-0.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81038ff87a4e04c22e1d81f947c6ac46f122e0c80460b9006e6517c4d842a6ec"}, - {file = "rpds_py-0.17.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:810685321f4a304b2b55577c915bece4c4a06dfe38f6e62d9cc1d6ca8ee86b99"}, - {file = "rpds_py-0.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25f071737dae674ca8937a73d0f43f5a52e92c2d178330b4c0bb6ab05586ffa6"}, - {file = "rpds_py-0.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa5bfb13f1e89151ade0eb812f7b0d7a4d643406caaad65ce1cbabe0a66d695f"}, - {file = "rpds_py-0.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfe07308b311a8293a0d5ef4e61411c5c20f682db6b5e73de6c7c8824272c256"}, - {file = "rpds_py-0.17.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a000133a90eea274a6f28adc3084643263b1e7c1a5a66eb0a0a7a36aa757ed74"}, - {file = "rpds_py-0.17.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d0e8a6434a3fbf77d11448c9c25b2f25244226cfbec1a5159947cac5b8c5fa4"}, - {file = "rpds_py-0.17.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efa767c220d94aa4ac3a6dd3aeb986e9f229eaf5bce92d8b1b3018d06bed3772"}, - {file = "rpds_py-0.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:dbc56680ecf585a384fbd93cd42bc82668b77cb525343170a2d86dafaed2a84b"}, - {file = "rpds_py-0.17.1-cp39-none-win32.whl", hash = "sha256:270987bc22e7e5a962b1094953ae901395e8c1e1e83ad016c5cfcfff75a15a3f"}, - {file = "rpds_py-0.17.1-cp39-none-win_amd64.whl", hash = "sha256:2a7b2f2f56a16a6d62e55354dd329d929560442bd92e87397b7a9586a32e3e76"}, - {file = "rpds_py-0.17.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a3264e3e858de4fc601741498215835ff324ff2482fd4e4af61b46512dd7fc83"}, - {file = "rpds_py-0.17.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f2f3b28b40fddcb6c1f1f6c88c6f3769cd933fa493ceb79da45968a21dccc920"}, - {file = "rpds_py-0.17.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9584f8f52010295a4a417221861df9bea4c72d9632562b6e59b3c7b87a1522b7"}, - {file = "rpds_py-0.17.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c64602e8be701c6cfe42064b71c84ce62ce66ddc6422c15463fd8127db3d8066"}, - {file = "rpds_py-0.17.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:060f412230d5f19fc8c8b75f315931b408d8ebf56aec33ef4168d1b9e54200b1"}, - {file = "rpds_py-0.17.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9412abdf0ba70faa6e2ee6c0cc62a8defb772e78860cef419865917d86c7342"}, - {file = "rpds_py-0.17.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9737bdaa0ad33d34c0efc718741abaafce62fadae72c8b251df9b0c823c63b22"}, - {file = "rpds_py-0.17.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9f0e4dc0f17dcea4ab9d13ac5c666b6b5337042b4d8f27e01b70fae41dd65c57"}, - {file = "rpds_py-0.17.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:1db228102ab9d1ff4c64148c96320d0be7044fa28bd865a9ce628ce98da5973d"}, - {file = "rpds_py-0.17.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:d8bbd8e56f3ba25a7d0cf980fc42b34028848a53a0e36c9918550e0280b9d0b6"}, - {file = "rpds_py-0.17.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:be22ae34d68544df293152b7e50895ba70d2a833ad9566932d750d3625918b82"}, - {file = "rpds_py-0.17.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:bf046179d011e6114daf12a534d874958b039342b347348a78b7cdf0dd9d6041"}, - {file = "rpds_py-0.17.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:1a746a6d49665058a5896000e8d9d2f1a6acba8a03b389c1e4c06e11e0b7f40d"}, - {file = "rpds_py-0.17.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0b8bf5b8db49d8fd40f54772a1dcf262e8be0ad2ab0206b5a2ec109c176c0a4"}, - {file = "rpds_py-0.17.1-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f7f4cb1f173385e8a39c29510dd11a78bf44e360fb75610594973f5ea141028b"}, - {file = "rpds_py-0.17.1-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7fbd70cb8b54fe745301921b0816c08b6d917593429dfc437fd024b5ba713c58"}, - {file = "rpds_py-0.17.1-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bdf1303df671179eaf2cb41e8515a07fc78d9d00f111eadbe3e14262f59c3d0"}, - {file = "rpds_py-0.17.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad059a4bd14c45776600d223ec194e77db6c20255578bb5bcdd7c18fd169361"}, - {file = "rpds_py-0.17.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3664d126d3388a887db44c2e293f87d500c4184ec43d5d14d2d2babdb4c64cad"}, - {file = "rpds_py-0.17.1-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:698ea95a60c8b16b58be9d854c9f993c639f5c214cf9ba782eca53a8789d6b19"}, - {file = "rpds_py-0.17.1-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:c3d2010656999b63e628a3c694f23020322b4178c450dc478558a2b6ef3cb9bb"}, - {file = "rpds_py-0.17.1-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:938eab7323a736533f015e6069a7d53ef2dcc841e4e533b782c2bfb9fb12d84b"}, - {file = "rpds_py-0.17.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e626b365293a2142a62b9a614e1f8e331b28f3ca57b9f05ebbf4cf2a0f0bdc5"}, - {file = "rpds_py-0.17.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:380e0df2e9d5d5d339803cfc6d183a5442ad7ab3c63c2a0982e8c824566c5ccc"}, - {file = "rpds_py-0.17.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b760a56e080a826c2e5af09002c1a037382ed21d03134eb6294812dda268c811"}, - {file = "rpds_py-0.17.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5576ee2f3a309d2bb403ec292d5958ce03953b0e57a11d224c1f134feaf8c40f"}, - {file = "rpds_py-0.17.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3c3461ebb4c4f1bbc70b15d20b565759f97a5aaf13af811fcefc892e9197ba"}, - {file = "rpds_py-0.17.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:637b802f3f069a64436d432117a7e58fab414b4e27a7e81049817ae94de45d8d"}, - {file = "rpds_py-0.17.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffee088ea9b593cc6160518ba9bd319b5475e5f3e578e4552d63818773c6f56a"}, - {file = "rpds_py-0.17.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3ac732390d529d8469b831949c78085b034bff67f584559340008d0f6041a049"}, - {file = "rpds_py-0.17.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:93432e747fb07fa567ad9cc7aaadd6e29710e515aabf939dfbed8046041346c6"}, - {file = "rpds_py-0.17.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:7b7d9ca34542099b4e185b3c2a2b2eda2e318a7dbde0b0d83357a6d4421b5296"}, - {file = "rpds_py-0.17.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:0387ce69ba06e43df54e43968090f3626e231e4bc9150e4c3246947567695f68"}, - {file = "rpds_py-0.17.1.tar.gz", hash = "sha256:0210b2668f24c078307260bf88bdac9d6f1093635df5123789bfee4d8d7fc8e7"}, -] - -[[package]] -name = "ruff" -version = "0.2.1" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -files = [ - {file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dd81b911d28925e7e8b323e8d06951554655021df8dd4ac3045d7212ac4ba080"}, - {file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dc586724a95b7d980aa17f671e173df00f0a2eef23f8babbeee663229a938fec"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c92db7101ef5bfc18e96777ed7bc7c822d545fa5977e90a585accac43d22f18a"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13471684694d41ae0f1e8e3a7497e14cd57ccb7dd72ae08d56a159d6c9c3e30e"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a11567e20ea39d1f51aebd778685582d4c56ccb082c1161ffc10f79bebe6df35"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:00a818e2db63659570403e44383ab03c529c2b9678ba4ba6c105af7854008105"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be60592f9d218b52f03384d1325efa9d3b41e4c4d55ea022cd548547cc42cd2b"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd2288890b88e8aab4499e55148805b58ec711053588cc2f0196a44f6e3d855"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ef052283da7dec1987bba8d8733051c2325654641dfe5877a4022108098683"}, - {file = "ruff-0.2.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7022d66366d6fded4ba3889f73cd791c2d5621b2ccf34befc752cb0df70f5fad"}, - {file = "ruff-0.2.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0a725823cb2a3f08ee743a534cb6935727d9e47409e4ad72c10a3faf042ad5ba"}, - {file = "ruff-0.2.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0034d5b6323e6e8fe91b2a1e55b02d92d0b582d2953a2b37a67a2d7dedbb7acc"}, - {file = "ruff-0.2.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e5cb5526d69bb9143c2e4d2a115d08ffca3d8e0fddc84925a7b54931c96f5c02"}, - {file = "ruff-0.2.1-py3-none-win32.whl", hash = "sha256:6b95ac9ce49b4fb390634d46d6ece32ace3acdd52814671ccaf20b7f60adb232"}, - {file = "ruff-0.2.1-py3-none-win_amd64.whl", hash = "sha256:e3affdcbc2afb6f5bd0eb3130139ceedc5e3f28d206fe49f63073cb9e65988e0"}, - {file = "ruff-0.2.1-py3-none-win_arm64.whl", hash = "sha256:efababa8e12330aa94a53e90a81eb6e2d55f348bc2e71adbf17d9cad23c03ee6"}, - {file = "ruff-0.2.1.tar.gz", hash = "sha256:3b42b5d8677cd0c72b99fcaf068ffc62abb5a19e71b4a3b9cfa50658a0af02f1"}, -] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "sniffio" -version = "1.3.0" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, - {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, -] - -[[package]] -name = "starlette" -version = "0.36.3" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.8" -files = [ - {file = "starlette-0.36.3-py3-none-any.whl", hash = "sha256:13d429aa93a61dc40bf503e8c801db1f1bca3dc706b10ef2434a36123568f044"}, - {file = "starlette-0.36.3.tar.gz", hash = "sha256:90a671733cfb35771d8cc605e0b679d23b992f8dcfad48cc60b38cb29aeb7080"}, -] - -[package.dependencies] -anyio = ">=3.4.0,<5" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} - -[package.extras] -full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] - -[[package]] -name = "stevedore" -version = "5.1.0" -description = "Manage dynamic plugins for Python applications" -optional = false -python-versions = ">=3.8" -files = [ - {file = "stevedore-5.1.0-py3-none-any.whl", hash = "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d"}, - {file = "stevedore-5.1.0.tar.gz", hash = "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c"}, -] - -[package.dependencies] -pbr = ">=2.0.0,<2.1.0 || >2.1.0" - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "tomlkit" -version = "0.12.3" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomlkit-0.12.3-py3-none-any.whl", hash = "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba"}, - {file = "tomlkit-0.12.3.tar.gz", hash = "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4"}, -] - -[[package]] -name = "tox" -version = "4.12.1" -description = "tox is a generic virtualenv management and test command line tool" -optional = false -python-versions = ">=3.8" -files = [ - {file = "tox-4.12.1-py3-none-any.whl", hash = "sha256:c07ea797880a44f3c4f200ad88ad92b446b83079d4ccef89585df64cc574375c"}, - {file = "tox-4.12.1.tar.gz", hash = "sha256:61aafbeff1bd8a5af84e54ef6e8402f53c6a6066d0782336171ddfbf5362122e"}, -] - -[package.dependencies] -cachetools = ">=5.3.2" -chardet = ">=5.2" -colorama = ">=0.4.6" -filelock = ">=3.13.1" -packaging = ">=23.2" -platformdirs = ">=4.1" -pluggy = ">=1.3" -pyproject-api = ">=1.6.1" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -virtualenv = ">=20.25" - -[package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.25.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] -testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=8.0.2)", "distlib (>=0.3.8)", "flaky (>=3.7)", "hatch-vcs (>=0.4)", "hatchling (>=1.21)", "psutil (>=5.9.7)", "pytest (>=7.4.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-xdist (>=3.5)", "re-assert (>=1.1)", "time-machine (>=2.13)", "wheel (>=0.42)"] - -[[package]] -name = "typing-extensions" -version = "4.9.0" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, -] - -[[package]] -name = "urllib3" -version = "2.2.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -files = [ - {file = "urllib3-2.2.0-py3-none-any.whl", hash = "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224"}, - {file = "urllib3-2.2.0.tar.gz", hash = "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "uvicorn" -version = "0.27.1" -description = "The lightning-fast ASGI server." -optional = false -python-versions = ">=3.8" -files = [ - {file = "uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4"}, - {file = "uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a"}, -] - -[package.dependencies] -click = ">=7.0" -h11 = ">=0.8" -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} - -[package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] - -[[package]] -name = "virtualenv" -version = "20.25.0" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.7" -files = [ - {file = "virtualenv-20.25.0-py3-none-any.whl", hash = "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3"}, - {file = "virtualenv-20.25.0.tar.gz", hash = "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] - -[[package]] -name = "watchdog" -version = "4.0.0" -description = "Filesystem events monitoring" -optional = false -python-versions = ">=3.8" -files = [ - {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b"}, - {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b"}, - {file = "watchdog-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c"}, - {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b"}, - {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935"}, - {file = "watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b"}, - {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257"}, - {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19"}, - {file = "watchdog-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b"}, - {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:45cc09cc4c3b43fb10b59ef4d07318d9a3ecdbff03abd2e36e77b6dd9f9a5c85"}, - {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eed82cdf79cd7f0232e2fdc1ad05b06a5e102a43e331f7d041e5f0e0a34a51c4"}, - {file = "watchdog-4.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba30a896166f0fee83183cec913298151b73164160d965af2e93a20bbd2ab605"}, - {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d18d7f18a47de6863cd480734613502904611730f8def45fc52a5d97503e5101"}, - {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2895bf0518361a9728773083908801a376743bcc37dfa252b801af8fd281b1ca"}, - {file = "watchdog-4.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87e9df830022488e235dd601478c15ad73a0389628588ba0b028cb74eb72fed8"}, - {file = "watchdog-4.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b"}, - {file = "watchdog-4.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92"}, - {file = "watchdog-4.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269"}, - {file = "watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c"}, - {file = "watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245"}, - {file = "watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7"}, - {file = "watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec"}, -] - -[package.extras] -watchmedo = ["PyYAML (>=3.10)"] - -[[package]] -name = "zipp" -version = "3.17.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, - {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] - -[metadata] -lock-version = "2.0" -python-versions = ">=3.8,<4.0" -content-hash = "c078d94b2eedb540fc6d4a6acbeb43f1bd44869d56395d43c59504748a6a7cf0" From 4181f9f5d55b51c15397b5ebe60fb04f7f32c7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ezequiel=20Leonardo=20Casta=C3=B1o?= <14986783+ELC@users.noreply.github.com> Date: Mon, 12 Feb 2024 20:22:27 +0000 Subject: [PATCH 66/66] Add lock file to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c2e68ff..2bbb7e1 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,6 @@ cython_debug/ # IDE files .vscode/ + +# Poetry Lock +poetry.lock \ No newline at end of file
ModelFormat Response
-```python -class ItemSummary(HyperModel): - name: str - id: str - href = UrlFor( - "read_item", {"item_id": ""} - ) -``` +No Hypermdia -```json +```json linenums="1" { - "name": "Foo", - "id": "item01", - "href": "/items/item01" + "id_": "item01", + "name": "Foo", + "price": 10.2, } ```
-```python -class ItemSummary(HyperModel): - name: str - id: str - link = HALFor( - "read_item", {"item_id": ""}, - description="Read an item" - ) +Level 0 Hypermedia (URLFor) + + + +```json linenums="1" +{ + "id_": "item01", + "name": "Foo", + "price": 10.2, + + "href": "/items/item01", + "update": "/items/item01" +} ```
-```json +Level 1 Hypermedia (HAL) + + + +```json linenums="1" { - "name": "Foo", - "id": "item01", - "link": { - "href": "/items/item01", - "method": "GET", - "description": "Read an item" - } + "id_": "item01", + "name": "Foo", + "price": 10.2, + + "_links": { + "self": {"href": "/items/item01"}, + "update": {"href": "/items/item01"}, + }, } ``` +
+ +Level 2 Hypermedia (Siren) + + + +```json linenums="1" +{ + "properties": { + "id_": "item01", + "name": "Foo", + "price": 10.2 + }, + "links": [ + { + "rel": ["self"], + "href": "/items/item01" + } + ], + "actions": [ + { + "name": "update", + "method": "PUT", + "href": "/items/item01", + "type": "application/x-www-form-urlencoded", + "fields": [ + { + "name": "name", + "type": "text", + "value": "Foo" + }, + { + "name": "description", + "type": "text", + "value": "None" + }, + { + "name": "price", + "type": "number", + "value": "10.2" + } + ] + } + ] +} +```