From e5034479b028ef791a4947b5df5a14cf248e935c Mon Sep 17 00:00:00 2001 From: Peter Schutt Date: Mon, 28 Nov 2022 16:17:38 +1000 Subject: [PATCH] feat(dto) - rework of dto pattern (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(dto): decorator based pydantic models gen with validation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * ♻️ refactor: fix mypy * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * ♻️ refactor: fix pylint * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat(dto): refactor dto.factory. Moves the core changes from dto.dto() into dto.factory() to centralize logic. * Update src/starlite_saqlalchemy/dto.py * feat(dto): more iteration. - Adds `dto.Attrib.pydantic_type` and `dto.Attrib.validators` where `pydantic_type` allows the dto field type to be explicitly set, and `validators` allows arbitrary single argument callables to be specified as dto validators for any model attribute. - `factory()` should now handle `MappedAsDataclass` sqla models - `decorator()` is for decorating pydantic models, and uses the decorated class as the base class for the model. - adds some tests. * feat(dto): more iteration. Further development of the decorator pattern. * feat!(dto): refactor of DTOs.. again. Have done away with both `factory()` and `decorator()`, as neither plays nicely with static type checkers. Using `FromMapped[Annotated[Author, dto.config("read")]]` has the same result as the old `dto.factory("DTO", Author, dto.Purpose.READ)`. In `FromMapped.__class_getitem__()` we create a new class, assign the model to it, and return it inplace of `FromMapped`. This new class can be subclassed in order to define pydantic validators and override types, just as any other pydantic model would be. * feat!(dto): more dto iteration. This PR refreshes DTO docs, gets test cov up to 100%, and adds some ergonomic improvements to the dto declaration syntax. Co-authored-by: gazorby Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Matthieu MN <10926130+gazorby@users.noreply.github.com> --- .flake8 | 2 + CHANGELOG.md | 4 +- docs/dto.md | 216 +++++++++++------- examples/dto/__init__.py | 0 examples/dto/complete_field_example.py | 34 +++ examples/dto/minimal.py | 51 +++++ examples/dto/minimal_configure_fields.py | 41 ++++ examples/tests/test_dto.py | 12 + mypy.ini | 5 +- poetry.lock | 151 +++---------- pyproject.toml | 2 + src/starlite_saqlalchemy/db/orm.py | 25 +-- src/starlite_saqlalchemy/dto.py | 158 ------------- src/starlite_saqlalchemy/dto/__init__.py | 14 ++ src/starlite_saqlalchemy/dto/from_mapped.py | 223 +++++++++++++++++++ src/starlite_saqlalchemy/dto/types.py | 83 +++++++ src/starlite_saqlalchemy/dto/utils.py | 59 +++++ src/starlite_saqlalchemy/init_plugin.py | 2 +- src/starlite_saqlalchemy/repository/types.py | 25 +-- src/starlite_saqlalchemy/settings.py | 2 +- src/starlite_saqlalchemy/testing.py | 8 +- tests/unit/test_dto.py | 190 ++++++++++++++-- tests/unit/test_endpoint_decorator.py | 1 - tests/unit/test_orm.py | 9 - tests/utils/controllers.py | 10 +- tests/utils/domain/authors.py | 20 +- tests/utils/domain/books.py | 7 +- tox.ini | 2 +- 28 files changed, 881 insertions(+), 475 deletions(-) create mode 100644 examples/dto/__init__.py create mode 100644 examples/dto/complete_field_example.py create mode 100644 examples/dto/minimal.py create mode 100644 examples/dto/minimal_configure_fields.py create mode 100644 examples/tests/test_dto.py delete mode 100644 src/starlite_saqlalchemy/dto.py create mode 100644 src/starlite_saqlalchemy/dto/__init__.py create mode 100644 src/starlite_saqlalchemy/dto/from_mapped.py create mode 100644 src/starlite_saqlalchemy/dto/types.py create mode 100644 src/starlite_saqlalchemy/dto/utils.py diff --git a/.flake8 b/.flake8 index 5de32352..96e08939 100644 --- a/.flake8 +++ b/.flake8 @@ -4,6 +4,8 @@ max-line-length = 100 ignore = E,W,B008 type-checking-exempt-modules = from sqlalchemy.orm per-file-ignores = + examples/dto/*:T201,TC + examples/tests/*:SCS108,PT013 src/starlite_saqlalchemy/dependencies.py:TC src/starlite_saqlalchemy/health.py:TC src/starlite_saqlalchemy/repository/filters.py:TC diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c48be9..0df4032a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,14 +85,14 @@ ## v0.3.0 (2022-11-07) ### Feature -* **dto:** Dto.Attrib and dto.Mark. ([`ca47028`](https://github.com/topsport-com-au/starlite-saqlalchemy/commit/ca47028f674b696493564d07379b589756433cc1)) +* **dto:** Dto.DTOField and dto.Mark. ([`ca47028`](https://github.com/topsport-com-au/starlite-saqlalchemy/commit/ca47028f674b696493564d07379b589756433cc1)) ### Fix * **log:** Fix dev config write to bytes logger. ([`2d8c69e`](https://github.com/topsport-com-au/starlite-saqlalchemy/commit/2d8c69ec93083d1d6dc42ebceb8e43b02cde9408)) * Removes static files config. ([`bdf5ea5`](https://github.com/topsport-com-au/starlite-saqlalchemy/commit/bdf5ea56d4f04f6fa7b907492d305417e48be9f1)) ### Breaking -* dto.Attrib and dto.Mark. ([`ca47028`](https://github.com/topsport-com-au/starlite-saqlalchemy/commit/ca47028f674b696493564d07379b589756433cc1)) +* dto.DTOField and dto.Mark. ([`ca47028`](https://github.com/topsport-com-au/starlite-saqlalchemy/commit/ca47028f674b696493564d07379b589756433cc1)) * removes static files config. ([`bdf5ea5`](https://github.com/topsport-com-au/starlite-saqlalchemy/commit/bdf5ea56d4f04f6fa7b907492d305417e48be9f1)) ### Documentation diff --git a/docs/dto.md b/docs/dto.md index 862c97f4..0b39cd8d 100644 --- a/docs/dto.md +++ b/docs/dto.md @@ -5,127 +5,173 @@ DTO stands for "Data Transfer Object". They are the filter through which data is accepted into, and output from the application. -## DTO Factory +## Why DTOs? -`starlite-saqlalchemy` includes -[`dto.factory()`](../reference/starlite_saqlalchemy/dto/#starlite_saqlalchemy.dto.factory) -which automatically creates [pydantic](https://pydantic-docs.helpmanual.io/) models from -[SQLAlchemy 2.0 ORM](https://docs.sqlalchemy.org/en/20/orm/) models. +Data that is modifiable by clients, and that should be read by clients is often only a subset of the +attributes that make up a domain object. -### Creating a SQLAlchemy ORM model +For example, lets say we have an internal representation of an `Author` that looks like this: -If you are new to SQLAlchemy, I cannot recommend their docs enough. Start at -[the beginning](https://docs.sqlalchemy.org/en/20/orm/quickstart.html), and follow along until you -are comfortable. I won't even try to compete with the quality and depth of information that can be -found there - a credit to everyone who has contributed to that project over the years. +```json +{ + "id": "97108ac1-ffcb-411d-8b1e-d9183399f63b", + "name": "Agatha Christie", + "dob": "1890-9-15", + "created": "2022-11-27T01:58:00", + "updated": "2022-11-27T01:59:00" +} +``` -### Configuring generated DTOs +Of those attributes, values for "id", "created" and "updated" are internally generated, and should +not be available to be modified by clients of our application. -#### dto.Purpose +This is where a DTO comes in. We create a type to validate user input that will only allow values +for "name" and "dob" from clients, for example: -The `dto.Purpose` enum tells the factory if the purpose of the DTO is to parse data submitted by the -client for updating or "writing" to a resource, or if it is to serialize data to be transmitted back -to, or "read" by the client. +```json +{ + "name": "Agatha Christie", + "dob": "1890-9-15" +} +``` -The DTO objects generated by the factory may differ due to the intended purpose of the DTO. Here's -an example of setting the DTO purpose: +## dto.FromMapped -```python -from starlite_saqlalchemy import dto +- Generate pydantic models from SQLAlchemy ORM models. +- Mark fields as "read-only" or "private" to control inclusion of fields on DTO models. +- Automatically infer defaults, and default factories from the SQLAlchemy column definitions. -from domain.users import User +The [`dto.FromMapped`](../reference/starlite_saqlalchemy/dto/#starlite_saqlalchemy.dto.FromMapped) +type allows us to use our domain models, which are defined as SQLAlchemy ORM types, to generate +DTOs. +Here's a quick example. -ReadDTO = dto.factory("UserReadDTO", model=User, purpose=dto.Purpose.READ) -WriteDTO = dto.factory("UserWriteDTO", model=User, purpose=dto.Purpose.WRITE) +```py title="Simple Example" +--8<-- "examples/dto/minimal.py" ``` -#### dto.Attrib +Read the comments in the example for a description of everything that is going on, however notice +that the fields on our DTO type include "id", "created", and "updated" - fields that should not be +modifiable by clients. -The `dto.Attrib` object is a container for configuring how the generated DTO object should reflect -the SQLAlchemy model field. +Let's have another go: -Define this in the SQLAlchemy `info` parameter to `mapped_column()`, for example, -`mapped_column(info={"dto": dto.Attrib()})`. +```py title="Simple Example with Read Only Fields" +--8<-- "examples/dto/minimal_configure_fields.py" +``` -The DTO Attrib object has two values that can be set: +That's better! Now, we'll only parse "name" and "dob" fields out of client input. -- `dto.Attrib.mark`: a value of the enum [`dto.Mark`](.. /reference/starlite_saqlalchemy/dto/#Mark). -- `dto.Attrib.pydantic_field`: return value of the pydantic - [`Field`](https://pydantic-docs.helpmanual.io/usage/schema/#field-customization) function that - will be used to construct the pydantic model field for the model attribute. +## Configuring generated DTOs -#### dto.Mark +Th two main factors that influence how a DTO is generated for a given domain model are: -We use the `info` parameter to `mapped_column()` to guide `dto.factory()`. +1. The modifiability and privacy of the individual attributes of the domain model. +2. The purpose of the DTO, is it to be used to parse and validate inbound client data, or to + serialize outbound data. -The [dto.Mark](../reference/starlite_saqlalchemy/dto/#Mark) enumeration is used to indicate on the -whether properties on the SQLAlchemy model should always be private, or read-only. +## Configuring DTO Fields -For example: +### dto.DTOField -```python -from datetime import datetime +This is the object that we use to configure DTO fields. To use the +[`dto.DTOField`][starlite_saqlalchemy.dto.types.DTOField] object assign a dict to the `mapped_column()` or +`relationship` `info` parameter, with the `"dto"` key and an instance of `dto.DTOField` as value, for +example `col: Mapped[str] = mapped_column(info={"dto": dto.DTOField(...)})`. -from sqlalchemy.orm import mapped_column -from starlite_saqlalchemy import dto, orm +The `dto.DTOField` object supports marking fields as `"read-only"` or `"private"`, setting an explicit +pydantic `FieldInfo` and type, and setting validators for the field. +The easiest way to configure a DTO field is through the +[`dto.field()`][starlite_saqlalchemy.dto.utils.field] function. -class User(orm.Base): - name: str - password_hash: str = mapped_column(info={"dto": dto.Attrib(mark=dto.Mark.SKIP)}) - updated_at: datetime = mapped_column( - info={"dto": dto.Attrib(mark=dto.Mark.READ_ONLY)} - ) +### dto.field() +The [`dto.field()`][starlite_saqlalchemy.dto.utils.field] function creates an `info` dict for us, +setting values on an `dto.DTOField` instance as appropriate. For example, the following are identical: -ReadDTO = dto.factory("UserReadDTO", model=User, purpose=dto.Purpose.READ) -WriteDTO = dto.factory("UserWriteDTO", model=User, purpose=dto.Purpose.WRITE) -``` +- `col: Mapped[str] = mapped_column(info={"dto": dto.DTOField(mark=dto.Mark.PRIVATE)})` +- `col: Mapped[str] = mapped_column(info=dto.field("private"))` -Both `ReadDTO` and `WriteDTO` are pydantic models that have a `name` attribute. +`field()` supports the same arguments as `DTOField`, however it will also coerce string values for mark +to the appropriate enum. -Neither `ReadDTO` or `WriteDTO` have a `password_hash` attribute - this is the side effect of -marking the column with `dto.Mark.SKIP`. Skipped columns are never included in any generated DTO -model, meaning that they are unable to be read or modified by the client. +### dto.Mark -`ReadDTO` has an `updated_at` field, while `WriteDTO` does not. This is the side effect of marking -the column with `dto.Mark.READ_ONLY` - these fields will only be included in DTOs generated for -`dto.Purpose.READ` and make sense for fields that have internally generated values. +Fields on our domain models can take one of three states. -The following class is pretty much the same as -[`orm.Base`](../reference/starlite_saqlalchemy/orm/#starlite_saqlalchemy.orm.Base) - the bundled -SQLAlchemy base class that comes with `starlite-saqlalchemy`. +1. Normal - field can be written to, and read by clients, this is the state of unmarked fields. +2. Read-only - field can be read by clients, but not modified. +3. Private - field can not be read or updated by client. -```python -from datetime import datetime -from uuid import UUID, uuid4 +The [`dto.Mark`][starlite_saqlalchemy.dto.types.Mark] enumeration lets us express these states on +our domain models. -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +`dto.field()` will accept the mark values as either the explicit enum, or its string representation, +e.g., `dto.field(dto.Mark.PRIVATE)` and `dto.field("private")` are equivalent. -from starlite_saqlalchemy import dto +### Example +The following example demonstrates all field configurations available via the `field()` function. -class Base(DeclarativeBase): - id: Mapped[UUID] = mapped_column( - default=uuid4, - primary_key=True, - info={"dto": dto.Attrib(mark=dto.Mark.READ_ONLY)}, - ) - """Primary key column.""" - created: Mapped[datetime] = mapped_column( - default=datetime.now, info={"dto": dto.Attrib(mark=dto.Mark.READ_ONLY)} - ) - """Date/time of instance creation.""" - updated: Mapped[datetime] = mapped_column( - default=datetime.now, info={"dto": dto.Attrib(mark=dto.Mark.READ_ONLY)} - ) +```py title="DTOField Configuration Example" +--8<-- "examples/dto/complete_field_example.py" ``` -Notice that all these fields are marked as `dto.Mark.READ_ONLY`. This means that they are unable to -be modified by clients, even if they include values for them in the payloads to `POST`/`PUT`/`PATCH` -routes. +### SQLAlchemy info dictionary + +SQLAlchemy [`Column`][sqlalchemy.schema.Column] and [`relationship`][sqlalchemy.orm.relationship] +accept an `info` parameter, which allows us to store data alongside the columns and relationships of +our model definitions. This is what we use to configure our DTOs at the model level. + +### Info dict namespace key + +The key that is used to namespace our DTO configuration in the `info` dict is configurable via +environment. By default, this is `"dto"`, however it can be changed to anything you like by setting +the `API_DTO_INFO_KEY` environment variable. + +## Configuring DTO Objects + +### dto.DTOConfig + +This is the object that controls the generated DTO, and should be passed as the first argument to +`Annotated` when declaring the DTO. For example, to create a "read" purposed DTO that excludes the +"id" field: + +`ReadDTO = dto.FromMapped[Annotated[Author, dto.DTOConfig(purpose=dto.Purpose.READ, exclude={"id"})]]` + +The [`dto.config()`][starlite_saqlalchemy.dto.utils.config] function allows for more compact +expression of DTO configuration. + +### dto.config() + +Factory function for creating `DTOConfig` instances, and handles coercing the literal strings "read" +and "write" to their `dto.Purpose` enum counterpart. + +For example to create a write purposed DTO using the `dto.config()` function: + +`WriteDTO = dto.FromMapped[Annotated[Author, dto.config("write")]]` + +Which is equivalent to: + +`WriteDTO = dto.FromMapped[Annotated[Author, dto.DTOConfig(purpose=dto.Purpose.WRITE)]]` + +### Annotated positional arguments + +The first argument to [`Annotated`][typing.Annotated] must always be the SQLAlchemy ORM type. +We inspect a single additional positional argument after that, which can either be the string name +of a `dto.Purpose` enum, or a `dto.DTOConfig` object. + +For example, these three definitions are equivalent: + +- `WriteDTO = dto.FromMapped[Annotated[Author, dto.DTOConfig(purpose=dto.Purpose.WRITE)]]` +- `WriteDTO = dto.FromMapped[Annotated[Author, dto.config("write")]]` +- `WriteDTO = dto.FromMapped[Annotated[Author, "write"]]` + +### dto.Purpose -You can inherit from `orm.Base` to create your SQLAlchemy models, but you don't have to. You can -choose to subclass `orm.Base` or roll your own base class altogether. `dto.factory()` will still -work as advertised. +`dto.Purpose` has two values, `dto.Purpose.READ` and `dto.Purpose.WRITE`. These are used to tell +the factory if the purpose of the DTO is to parse data submitted by the client for updating or +"writing" to a resource, or if it is to serialize data to be transmitted back to, or "read" by the +client. diff --git a/examples/dto/__init__.py b/examples/dto/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/dto/complete_field_example.py b/examples/dto/complete_field_example.py new file mode 100644 index 00000000..5e8d30aa --- /dev/null +++ b/examples/dto/complete_field_example.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pydantic import Field, constr +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from starlite_saqlalchemy import dto + + +def check_email(email: str) -> str: + """Validate an email.""" + if "@" not in email: + raise ValueError("Invalid email!") + return email + + +class Base(DeclarativeBase): + """Our ORM base class.""" + + +class Thing(Base): + """Something in our domain.""" + + __tablename__ = "things" + # demonstrates marking a field as "read-only" and overriding the generated pydantic `FieldInfo` + # for the DTO field. + id = mapped_column( + primary_key=True, info=dto.field("read-only", pydantic_field=Field(alias="identifier")) + ) + # demonstrates overriding the type assigned to the field in generated DTO + always_upper: Mapped[str] = mapped_column(info=dto.field(pydantic_type=constr(to_upper=True))) + # demonstrates setting a field as "private" + private: Mapped[str] = mapped_column(info=dto.field("private")) + # demonstrates setting a validator for the field + email: Mapped[str] = mapped_column(info=dto.field(validators=[check_email])) diff --git a/examples/dto/minimal.py b/examples/dto/minimal.py new file mode 100644 index 00000000..ccfcd77b --- /dev/null +++ b/examples/dto/minimal.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import date, datetime +from typing import Annotated + +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from starlite_saqlalchemy import dto + + +class Base(DeclarativeBase): + """ORM base class. + + All SQLAlchemy ORM models must inherit from DeclarativeBase. We also + define some common columns that we want to be present on every model + in our domain. + """ + + id: Mapped[int] = mapped_column(primary_key=True) + created: Mapped[datetime] + updated: Mapped[datetime] + + +class Author(Base): + """A domain model. + + In addition to the columns defined on `Base` we have "name" and + "dob". + """ + + __tablename__ = "authors" + name: Mapped[str] + dob: Mapped[date] + + +# This creates a DTO, which is simply a Pydantic model that inherits +# from our special `FromMapped` subclass. We call it "WriteDTO" as it +# is the model that we'll use to parse client data as they try to +# "write" to (that is, create or update) authors in our domain. +WriteDTO = dto.FromMapped[Annotated[Author, "write"]] + +# we can inspect the fields that are available on the DTO +print(WriteDTO.__fields__) + +# { +# "id": ModelField(name="id", type=int, required=True), +# "created": ModelField(name="created", type=datetime, required=True), +# "updated": ModelField(name="updated", type=datetime, required=True), +# "name": ModelField(name="name", type=str, required=True), +# "dob": ModelField(name="dob", type=date, required=True), +# } diff --git a/examples/dto/minimal_configure_fields.py b/examples/dto/minimal_configure_fields.py new file mode 100644 index 00000000..b2d84bbf --- /dev/null +++ b/examples/dto/minimal_configure_fields.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from datetime import date, datetime +from typing import Annotated + +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from starlite_saqlalchemy import dto + + +class Base(DeclarativeBase): + """ORM base class. + + Using the dto.field() function, we've annotated that these columns + are read-only. + """ + + id: Mapped[int] = mapped_column(primary_key=True, info=dto.field("read-only")) + created: Mapped[datetime] = mapped_column(info=dto.field("read-only")) + updated: Mapped[datetime] = mapped_column(info=dto.field("read-only")) + + +class Author(Base): + """Domain object.""" + + __tablename__ = "authors" + name: Mapped[str] + dob: Mapped[date] + + +WriteDTO = dto.FromMapped[Annotated[Author, "write"]] + +# now when we inspect our fields, we can see that our "write" purposed +# DTO does not include any of the fields that we marked as "read-only" +# fields. +print(WriteDTO.__fields__) + +# { +# "name": ModelField(name="name", type=str, required=True), +# "dob": ModelField(name="dob", type=date, required=True), +# } diff --git a/examples/tests/test_dto.py b/examples/tests/test_dto.py new file mode 100644 index 00000000..7531e690 --- /dev/null +++ b/examples/tests/test_dto.py @@ -0,0 +1,12 @@ +"""Tests for DTO examples.""" +from examples.dto import minimal, minimal_configure_fields + + +def test_minimal_example() -> None: + """Test the dto generated for the example.""" + assert minimal.WriteDTO.__fields__.keys() == {"id", "created", "updated", "name", "dob"} + + +def test_minimal_configure_fields() -> None: + """Test expected fields in minimal example with configured fields.""" + assert minimal_configure_fields.WriteDTO.__fields__.keys() == {"name", "dob"} diff --git a/mypy.ini b/mypy.ini index a4f69ede..d2d74a99 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2,7 +2,7 @@ check_untyped_defs = True disallow_any_generics = False -disallow_incomplete_defs = True +disallow_incomplete_defs = False disallow_untyped_decorators = True disallow_untyped_defs = True ignore_missing_imports = False @@ -18,9 +18,6 @@ warn_unused_ignores = True [mypy-tests.*] disallow_untyped_decorators = False -[mypy-tests.utils.controllers] -disable_error_code = valid-type - [mypy-tests.unit.test_dto] # for the declarative base fixture disable_error_code = valid-type,misc diff --git a/poetry.lock b/poetry.lock index 0b11d145..2eac727d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -65,7 +65,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7 [[package]] name = "croniter" -version = "1.3.7" +version = "1.3.8" description = "croniter provides iteration for datetime object with cron like format" category = "main" optional = false @@ -74,20 +74,6 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] python-dateutil = "*" -[[package]] -name = "Deprecated" -version = "1.2.13" -description = "Python @deprecated decorator to deprecate old python classes, functions or methods." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.dependencies] -wrapt = ">=1.10,<2" - -[package.extras] -dev = ["PyTest", "PyTest (<5)", "PyTest-Cov", "PyTest-Cov (<2.6)", "bump2version (<1)", "configparser (<5)", "importlib-metadata (<3)", "importlib-resources (<4)", "sphinx (<2)", "sphinxcontrib-websupport (<2)", "tox", "zipp (<2)"] - [[package]] name = "dnspython" version = "2.2.1" @@ -118,7 +104,7 @@ idna = ">=2.0.0" [[package]] name = "Faker" -version = "15.3.2" +version = "15.3.3" description = "Faker is a Python package that generates fake data for you." category = "main" optional = false @@ -157,7 +143,7 @@ python-versions = ">=3.6" [[package]] name = "httpcore" -version = "0.16.1" +version = "0.16.2" description = "A minimal low-level HTTP client." category = "main" optional = false @@ -245,11 +231,11 @@ email = ["email-validator (>=1.0.3)"] [[package]] name = "pydantic-factories" -version = "1.15.0" +version = "1.16.0" description = "Mock data generation for pydantic based models and python dataclasses" category = "main" optional = false -python-versions = ">=3.7,<4.0" +python-versions = ">=3.8,<4.0" [package.dependencies] faker = "*" @@ -311,7 +297,7 @@ python-versions = ">=3.6" [[package]] name = "redis" -version = "4.3.4" +version = "4.3.5" description = "Python client for Redis database and key-value store" category = "main" optional = false @@ -319,7 +305,6 @@ python-versions = ">=3.6" [package.dependencies] async-timeout = ">=4.0.2" -deprecated = ">=1.2.3" packaging = ">=20.4" [package.extras] @@ -359,7 +344,7 @@ web = ["aiohttp", "aiohttp-basicauth"] [[package]] name = "sentry-sdk" -version = "1.11.0" +version = "1.11.1" description = "Python client for Sentry (https://sentry.io)" category = "main" optional = false @@ -443,7 +428,7 @@ sqlcipher = ["sqlcipher3-binary"] [[package]] name = "starlite" -version = "1.40.1" +version = "1.42.0" description = "Performant, light and flexible ASGI API Framework" category = "main" optional = false @@ -484,7 +469,7 @@ anyio = "*" [[package]] name = "structlog" -version = "22.2.0" +version = "22.3.0" description = "Structured Logging for Python" category = "main" optional = false @@ -506,11 +491,11 @@ python-versions = ">=3.7" [[package]] name = "urllib3" -version = "1.26.12" +version = "1.26.13" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] @@ -545,14 +530,6 @@ dev = ["Cython (>=0.29.32,<0.30.0)", "Sphinx (>=4.1.2,<4.2.0)", "aiohttp", "flak docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=22.0.0,<22.1.0)", "pycodestyle (>=2.7.0,<2.8.0)"] -[[package]] -name = "wrapt" -version = "1.14.1" -description = "Module for decorators, wrappers and monkey patching." -category = "main" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" - [metadata] lock-version = "1.1" python-versions = "^3.10" @@ -618,12 +595,8 @@ colorama = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] croniter = [ - {file = "croniter-1.3.7-py2.py3-none-any.whl", hash = "sha256:12369c67e231c8ce5f98958d76ea6e8cb5b157fda4da7429d245a931e4ed411e"}, - {file = "croniter-1.3.7.tar.gz", hash = "sha256:72ef78d0f8337eb35393b8893ebfbfbeb340f2d2ae47e0d2d78130e34b0dd8b9"}, -] -Deprecated = [ - {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"}, - {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"}, + {file = "croniter-1.3.8-py2.py3-none-any.whl", hash = "sha256:d6ed8386d5f4bbb29419dc1b65c4909c04a2322bd15ec0dc5b2877bfa1b75c7a"}, + {file = "croniter-1.3.8.tar.gz", hash = "sha256:32a5ec04e97ec0837bcdf013767abd2e71cceeefd3c2e14c804098ce51ad6cd9"}, ] dnspython = [ {file = "dnspython-2.2.1-py3-none-any.whl", hash = "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f"}, @@ -634,8 +607,8 @@ email-validator = [ {file = "email_validator-1.3.0.tar.gz", hash = "sha256:553a66f8be2ec2dea641ae1d3f29017ab89e9d603d4a25cdaac39eefa283d769"}, ] Faker = [ - {file = "Faker-15.3.2-py3-none-any.whl", hash = "sha256:43da04aae745018e8bded768e74c84423d9dc38e4c498a53439e749d90e20bc0"}, - {file = "Faker-15.3.2.tar.gz", hash = "sha256:0094fe3340ad73c490d3ffccc59cc171b161acfccccd52925c70970ba23e6d6b"}, + {file = "Faker-15.3.3-py3-none-any.whl", hash = "sha256:b95b2423ef18d17dcd5977732a0bf0fbbde4937f10dce24ff804581f7f3ca4e9"}, + {file = "Faker-15.3.3.tar.gz", hash = "sha256:20d090e661bbe88a5d801ea5eb3d853564940352120c84c9a14968847aca2893"}, ] greenlet = [ {file = "greenlet-2.0.1-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:9ed358312e63bf683b9ef22c8e442ef6c5c02973f0c2a939ec1d7b50c974015c"}, @@ -747,8 +720,8 @@ hiredis = [ {file = "hiredis-2.0.0.tar.gz", hash = "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a"}, ] httpcore = [ - {file = "httpcore-0.16.1-py3-none-any.whl", hash = "sha256:8d393db683cc8e35cc6ecb02577c5e1abfedde52b38316d038932a84b4875ecb"}, - {file = "httpcore-0.16.1.tar.gz", hash = "sha256:3d3143ff5e1656a5740ea2f0c167e8e9d48c5a9bbd7f00ad1f8cff5711b08543"}, + {file = "httpcore-0.16.2-py3-none-any.whl", hash = "sha256:52c79095197178856724541e845f2db86d5f1527640d9254b5b8f6f6cebfdee6"}, + {file = "httpcore-0.16.2.tar.gz", hash = "sha256:c35c5176dc82db732acfd90b581a3062c999a72305df30c0fc8fafd8e4aca068"}, ] httpx = [ {file = "httpx-0.23.1-py3-none-any.whl", hash = "sha256:0b9b1f0ee18b9978d637b0776bfd7f54e2ca278e063e3586d8f01cda89e042a8"}, @@ -913,8 +886,8 @@ pydantic = [ {file = "pydantic-1.10.2.tar.gz", hash = "sha256:91b8e218852ef6007c2b98cd861601c6a09f1aa32bbbb74fab5b1c33d4a1e410"}, ] pydantic-factories = [ - {file = "pydantic_factories-1.15.0-py3-none-any.whl", hash = "sha256:04e1f125cb28cbe745f2fece4bd2b419786395dc7efabac74fe037dc5397490a"}, - {file = "pydantic_factories-1.15.0.tar.gz", hash = "sha256:5c0636a2c5f357390d0a528e70754b139b431dd675109c481c06c494b3b26432"}, + {file = "pydantic_factories-1.16.0-py3-none-any.whl", hash = "sha256:4917849704338c1dda0d081094852c625ad99966d26231bd09bb95021fa6b321"}, + {file = "pydantic_factories-1.16.0.tar.gz", hash = "sha256:478baaf7ce01905ea2b6dd6f68fddd2e98808c3386ad029e55c89e197101b705"}, ] pydantic-openapi-schema = [ {file = "pydantic-openapi-schema-1.3.0.tar.gz", hash = "sha256:2aed6913080f1dae94234e00d0905504c6aab65ab6afe246ed7aa98da989f69e"}, @@ -975,8 +948,8 @@ PyYAML = [ {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] redis = [ - {file = "redis-4.3.4-py3-none-any.whl", hash = "sha256:a52d5694c9eb4292770084fa8c863f79367ca19884b329ab574d5cb2036b3e54"}, - {file = "redis-4.3.4.tar.gz", hash = "sha256:ddf27071df4adf3821c4f2ca59d67525c3a82e5f268bed97b813cb4fabf87880"}, + {file = "redis-4.3.5-py3-none-any.whl", hash = "sha256:46652271dc7525cd5a9667e5b0ca983c848c75b2b8f7425403395bb8379dcf25"}, + {file = "redis-4.3.5.tar.gz", hash = "sha256:30c07511627a4c5c4d970e060000772f323174f75e745a26938319817ead7a12"}, ] rfc3986 = [ {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, @@ -987,8 +960,8 @@ saq = [ {file = "saq-0.9.1.tar.gz", hash = "sha256:071348f2132d997f7bbbcfe0628554936a7080b4ae284c0220f9e2e3c945d84b"}, ] sentry-sdk = [ - {file = "sentry-sdk-1.11.0.tar.gz", hash = "sha256:e7b78a1ddf97a5f715a50ab8c3f7a93f78b114c67307785ee828ef67a5d6f117"}, - {file = "sentry_sdk-1.11.0-py2.py3-none-any.whl", hash = "sha256:f467e6c7fac23d4d42bc83eb049c400f756cd2d65ab44f0cc1165d0c7c3d40bc"}, + {file = "sentry-sdk-1.11.1.tar.gz", hash = "sha256:675f6279b6bb1fea09fd61751061f9a90dca3b5929ef631dd50dc8b3aeb245e9"}, + {file = "sentry_sdk-1.11.1-py2.py3-none-any.whl", hash = "sha256:8b4ff696c0bdcceb3f70bbb87a57ba84fd3168b1332d493fcd16c137f709578c"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, @@ -1042,24 +1015,24 @@ SQLAlchemy = [ {file = "SQLAlchemy-2.0.0b3.tar.gz", hash = "sha256:8325a4648e639cb2010199f64fad679d2f4ec8ce7e6f424ee1a41b07940cadb6"}, ] starlite = [ - {file = "starlite-1.40.1-py3-none-any.whl", hash = "sha256:d9e73bdd9d63cb981ba53ebb89af4565d01ef715081bbbf8a43599d120e684a9"}, - {file = "starlite-1.40.1.tar.gz", hash = "sha256:c4d47a0a72f3e0a86e0135cfb939d0906cce0ff7c7f2d953bd426df4067bb106"}, + {file = "starlite-1.42.0-py3-none-any.whl", hash = "sha256:7d1c41e6763b852a38c1af5b9d5babd75a99b14015682b696d5768e0ad05d143"}, + {file = "starlite-1.42.0.tar.gz", hash = "sha256:be9b7054d7251d094d031f4dbf8afdb46a993600f64375cd380c3e73ce01a9ad"}, ] starlite-multipart = [ {file = "starlite-multipart-1.2.0.tar.gz", hash = "sha256:9ba2108cfd47de07240dc90e55397080ec9ca0c6992ec1097a02dd32ba4ca593"}, {file = "starlite_multipart-1.2.0-py3-none-any.whl", hash = "sha256:ceb5667d6c58bcbc305d5922ead791f0b50f0b9f658747144577a3dccffc5335"}, ] structlog = [ - {file = "structlog-22.2.0-py3-none-any.whl", hash = "sha256:bb1aa189214c30372a8afaa592f9556756aeb690e1ad7eb34391d7b5fa0f09b2"}, - {file = "structlog-22.2.0.tar.gz", hash = "sha256:4edfe1d9f15f8a4bf365a817f7182af5d610596c155434b7564bfe7d40c2753c"}, + {file = "structlog-22.3.0-py3-none-any.whl", hash = "sha256:b403f344f902b220648fa9f286a23c0cc5439a5844d271fec40562dbadbc70ad"}, + {file = "structlog-22.3.0.tar.gz", hash = "sha256:e7509391f215e4afb88b1b80fa3ea074be57a5a17d794bd436a5c949da023333"}, ] typing-extensions = [ {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, ] urllib3 = [ - {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, - {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, + {file = "urllib3-1.26.13-py2.py3-none-any.whl", hash = "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc"}, + {file = "urllib3-1.26.13.tar.gz", hash = "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"}, ] uvicorn = [ {file = "uvicorn-0.20.0-py3-none-any.whl", hash = "sha256:c3ed1598a5668208723f2bb49336f4509424ad198d6ab2615b7783db58d919fd"}, @@ -1097,69 +1070,3 @@ uvloop = [ {file = "uvloop-0.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:30babd84706115626ea78ea5dbc7dd8d0d01a2e9f9b306d24ca4ed5796c66ded"}, {file = "uvloop-0.17.0.tar.gz", hash = "sha256:0ddf6baf9cf11a1a22c71487f39f15b2cf78eb5bde7e5b45fbb99e8a9d91b9e1"}, ] -wrapt = [ - {file = "wrapt-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3"}, - {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef"}, - {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28"}, - {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59"}, - {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87"}, - {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1"}, - {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b"}, - {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462"}, - {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1"}, - {file = "wrapt-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320"}, - {file = "wrapt-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2"}, - {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4"}, - {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069"}, - {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310"}, - {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f"}, - {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656"}, - {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c"}, - {file = "wrapt-1.14.1-cp310-cp310-win32.whl", hash = "sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8"}, - {file = "wrapt-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164"}, - {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907"}, - {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3"}, - {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3"}, - {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d"}, - {file = "wrapt-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7"}, - {file = "wrapt-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00"}, - {file = "wrapt-1.14.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4"}, - {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1"}, - {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1"}, - {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff"}, - {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d"}, - {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1"}, - {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569"}, - {file = "wrapt-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed"}, - {file = "wrapt-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471"}, - {file = "wrapt-1.14.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248"}, - {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68"}, - {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d"}, - {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77"}, - {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7"}, - {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015"}, - {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a"}, - {file = "wrapt-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853"}, - {file = "wrapt-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c"}, - {file = "wrapt-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456"}, - {file = "wrapt-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f"}, - {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc"}, - {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1"}, - {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af"}, - {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b"}, - {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0"}, - {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57"}, - {file = "wrapt-1.14.1-cp38-cp38-win32.whl", hash = "sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5"}, - {file = "wrapt-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d"}, - {file = "wrapt-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383"}, - {file = "wrapt-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7"}, - {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86"}, - {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735"}, - {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b"}, - {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3"}, - {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3"}, - {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe"}, - {file = "wrapt-1.14.1-cp39-cp39-win32.whl", hash = "sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5"}, - {file = "wrapt-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb"}, - {file = "wrapt-1.14.1.tar.gz", hash = "sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d"}, -] diff --git a/pyproject.toml b/pyproject.toml index 770bd043..c7407472 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,8 @@ testpaths = ["tests/unit"] [tool.pylint.main] disable = [ "line-too-long", + "no-self-argument", + "too-few-public-methods", "too-many-arguments", ] enable = "useless-suppression" diff --git a/src/starlite_saqlalchemy/db/orm.py b/src/starlite_saqlalchemy/db/orm.py index ca1c075a..dbd6c531 100644 --- a/src/starlite_saqlalchemy/db/orm.py +++ b/src/starlite_saqlalchemy/db/orm.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING, Any, TypeVar +from typing import Any, TypeVar from uuid import UUID, uuid4 from sqlalchemy import MetaData @@ -19,13 +19,10 @@ from starlite_saqlalchemy import dto, settings -if TYPE_CHECKING: - from pydantic import BaseModel - BaseT = TypeVar("BaseT", bound="Base") DTO_KEY = settings.api.DTO_INFO_KEY -"""The key we use to reference `dto.Attrib` in the SQLAlchemy info dict.""" +"""The key we use to reference `dto.DTOField` in the SQLAlchemy info dict.""" convention = { "ix": "ix_%(column_0_label)s", @@ -64,15 +61,15 @@ class Base(DeclarativeBase): ) id: Mapped[UUID] = mapped_column( - default=uuid4, primary_key=True, info={DTO_KEY: dto.Attrib(mark=dto.Mark.READ_ONLY)} + default=uuid4, primary_key=True, info={DTO_KEY: dto.DTOField(mark=dto.Mark.READ_ONLY)} ) """Primary key column.""" created: Mapped[datetime] = mapped_column( - default=datetime.now, info={DTO_KEY: dto.Attrib(mark=dto.Mark.READ_ONLY)} + default=datetime.now, info={DTO_KEY: dto.DTOField(mark=dto.Mark.READ_ONLY)} ) """Date/time of instance creation.""" updated: Mapped[datetime] = mapped_column( - default=datetime.now, info={DTO_KEY: dto.Attrib(mark=dto.Mark.READ_ONLY)} + default=datetime.now, info={DTO_KEY: dto.DTOField(mark=dto.Mark.READ_ONLY)} ) """Date/time of instance update.""" @@ -81,15 +78,3 @@ class Base(DeclarativeBase): def __tablename__(cls) -> str: # pylint: disable=no-self-argument """Infer table name from class name.""" return cls.__name__.lower() - - @classmethod - def from_dto(cls: type[BaseT], dto_instance: BaseModel) -> BaseT: - """Construct an instance of the SQLAlchemy model from the Pydantic DTO. - - Args: - dto_instance: A pydantic model - - Returns: - An instance of the SQLAlchemy model. - """ - return cls(**dto_instance.dict(exclude_unset=True)) diff --git a/src/starlite_saqlalchemy/dto.py b/src/starlite_saqlalchemy/dto.py deleted file mode 100644 index 7e67e9e9..00000000 --- a/src/starlite_saqlalchemy/dto.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Using this implementation instead of the `starlite.SQLAlchemy` plugin DTO as -a POC for using the SQLAlchemy model type annotations to build the pydantic -model. - -Also experimenting with marking columns for DTO purposes using the -`SQLAlchemy.Column.info` field, which allows demarcation of fields that -should always be private, or read-only at the model declaration layer. -""" -from __future__ import annotations - -from enum import Enum, auto -from inspect import isclass -from typing import ( - TYPE_CHECKING, - Any, - NamedTuple, - cast, - get_args, - get_origin, - get_type_hints, -) - -from pydantic import BaseConfig, BaseModel, create_model -from pydantic.fields import FieldInfo -from sqlalchemy import inspect -from sqlalchemy.orm import DeclarativeBase, Mapped - -from starlite_saqlalchemy import settings - -if TYPE_CHECKING: - from sqlalchemy import Column - from sqlalchemy.orm import Mapper, RelationshipProperty - - -class Mark(str, Enum): - """For marking column definitions on the domain models. - - Example: - ```python - class Model(Base): - ... - updated_at: Mapped[datetime] = mapped_column(info={"dto": Mark.READ_ONLY}) - ``` - """ - - READ_ONLY = "read-only" - SKIP = "skip" - - -class Purpose(Enum): - """For identifying the purpose of a DTO to the factory. - - The factory will exclude fields marked as private or read-only on the domain model depending - on the purpose of the DTO. - - Example: - ```python - ReadDTO = dto.factory("AuthorReadDTO", Author, purpose=dto.Purpose.READ) - ``` - """ - - READ = auto() - WRITE = auto() - - -class Attrib(NamedTuple): - """For configuring DTO behavior on SQLAlchemy model fields.""" - - mark: Mark | None = None - """Mark the field as read only, or skip.""" - pydantic_field: FieldInfo | None = None - """If provided, used for the pydantic model for this attribute.""" - - -def _construct_field_info(elem: Column | RelationshipProperty, purpose: Purpose) -> FieldInfo: - default = getattr(elem, "default", None) - if purpose is Purpose.READ or default is None: - return FieldInfo(...) - if default.is_scalar: - return FieldInfo(default=default.arg) - if default.is_callable: - return FieldInfo(default_factory=lambda: default.arg({})) - raise ValueError("Unexpected default type") - - -def _get_dto_attrib(elem: Column | RelationshipProperty) -> Attrib: - return elem.info.get(settings.api.DTO_INFO_KEY, Attrib()) - - -def _should_exclude_field( - purpose: Purpose, elem: Column | RelationshipProperty, exclude: set[str], dto_attrib: Attrib -) -> bool: - if elem.key in exclude: - return True - if dto_attrib.mark is Mark.SKIP: - return True - if purpose is Purpose.WRITE and dto_attrib.mark is Mark.READ_ONLY: - return True - return False - - -def factory( - name: str, model: type[DeclarativeBase], purpose: Purpose, exclude: set[str] | None = None -) -> type[BaseModel]: - """Create a pydantic model class from a SQLAlchemy declarative ORM class. - - The fields that are included in the model can be controlled on the SQLAlchemy class - definition by including a "dto" key in the `Column.info` mapping. For example: - - ```python - class User(DeclarativeBase): - id: Mapped[UUID] = mapped_column( - default=uuid4, primary_key=True, info={"dto": Attrib(mark=dto.Mark.READ_ONLY)} - ) - email: Mapped[str] - password_hash: Mapped[str] = mapped_column(info={"dto": Attrib(mark=dto.Mark.SKIP)}) - ``` - - In the above example, a DTO generated for `Purpose.READ` will include the `id` and `email` - fields, while a model generated for `Purpose.WRITE` will only include a field for `email`. - Notice that columns marked as `Mark.SKIP` will not have a field produced in any DTO object. - - Args: - name: Name given to the DTO class. - model: The SQLAlchemy model class. - purpose: Is the DTO for write or read operations? - exclude: Explicitly exclude attributes from the DTO. - - Returns: - A Pydantic model that includes only fields that are appropriate to `purpose` and not in - `exclude`. - """ - exclude = exclude or set[str]() - mapper = cast("Mapper", inspect(model)) - columns = mapper.columns - relationships = mapper.relationships - fields: dict[str, tuple[Any, FieldInfo]] = {} - for key, type_hint in get_type_hints(model).items(): - if get_origin(type_hint) is not Mapped: - continue - elem: Column | RelationshipProperty - try: - elem = columns[key] - except KeyError: - elem = relationships[key] - attrib = _get_dto_attrib(elem) - if _should_exclude_field(purpose, elem, exclude, attrib): - continue - (type_,) = get_args(type_hint) - if isclass(type_) and issubclass(type_, DeclarativeBase): - type_ = factory(f"{name}_{type_.__name__}", type_, purpose=purpose) - fields[key] = (type_, _construct_field_info(elem, purpose)) - return create_model( # type:ignore[no-any-return,call-overload] - name, - __config__=type("Config", (BaseConfig,), {"orm_mode": True}), - __module__=getattr(model, "__module__", "starlite_saqlalchemy.dto"), - **fields, - ) diff --git a/src/starlite_saqlalchemy/dto/__init__.py b/src/starlite_saqlalchemy/dto/__init__.py new file mode 100644 index 00000000..99388ae4 --- /dev/null +++ b/src/starlite_saqlalchemy/dto/__init__.py @@ -0,0 +1,14 @@ +"""Construct Pydantic models from SQLAlchemy ORM types.""" +from .from_mapped import FromMapped +from .types import DTOConfig, DTOField, Mark, Purpose +from .utils import config, field + +__all__ = ( + "DTOField", + "DTOConfig", + "FromMapped", + "Mark", + "Purpose", + "config", + "field", +) diff --git a/src/starlite_saqlalchemy/dto/from_mapped.py b/src/starlite_saqlalchemy/dto/from_mapped.py new file mode 100644 index 00000000..c86cec2d --- /dev/null +++ b/src/starlite_saqlalchemy/dto/from_mapped.py @@ -0,0 +1,223 @@ +"""Using this implementation instead of the `starlite.SQLAlchemy` plugin DTO as +a POC for using the SQLAlchemy model type annotations to build the pydantic +model. + +Also experimenting with marking columns for DTO purposes using the +`SQLAlchemy.Column.info` field, which allows demarcation of fields that +should always be private, or read-only at the model declaration layer. +""" +from __future__ import annotations + +from inspect import getmodule +from typing import ( + TYPE_CHECKING, + Annotated, + ClassVar, + Generic, + TypeVar, + cast, + get_args, + get_origin, + get_type_hints, +) + +from pydantic import BaseModel, create_model, validator +from pydantic.fields import FieldInfo +from sqlalchemy import inspect +from sqlalchemy.orm import DeclarativeBase, Mapped + +from starlite_saqlalchemy import settings + +from .types import DTOField, Mark, Purpose +from .utils import config + +if TYPE_CHECKING: + from typing import Any, Literal + + from pydantic.typing import AnyClassMethod + from sqlalchemy import Column + from sqlalchemy.orm import Mapper, RelationshipProperty + from sqlalchemy.sql.base import ReadOnlyColumnCollection + from sqlalchemy.util import ReadOnlyProperties + + from .types import DTOConfig + +__all__ = ("FromMapped",) + + +AnyDeclarative = TypeVar("AnyDeclarative", bound=DeclarativeBase) + + +class FromMapped(BaseModel, Generic[AnyDeclarative]): + """Produce an SQLAlchemy instance with values from a pydantic model.""" + + __sqla_model__: ClassVar[type[DeclarativeBase]] + + class Config: + """Set orm_mode for `to_mapped()` method.""" + + orm_mode = True + + def __class_getitem__( + cls, item: Annotated[type[AnyDeclarative], DTOConfig | Literal["read", "write"]] + ) -> type[FromMapped[AnyDeclarative]]: + """Decorate `cls` with result from `factory()`. + + Args: + item: Can be either of a SQLAlchemy ORM instance, or a `typing.Annotated` annotation + where the first argument is a SQLAlchemy ORM instance, and the second is an instance + of `DTOConfig`. + + Returns: + A new Pydantic model type, with `cls` as its base class, and additional fields derived + from the SQLAlchemy model, respecting any declared configuration. + """ + if get_origin(item) is Annotated: + model, pos_arg, *_ = get_args(item) + if isinstance(pos_arg, str): + dto_config = config(pos_arg) # type:ignore[arg-type] + else: + dto_config = pos_arg + else: + raise ValueError("Unexpected type annotation for `FromMapped`.") + return cls._factory( + cls.__name__, + cast("type[AnyDeclarative]", model), + dto_config.purpose, + exclude=dto_config.exclude, + ) + + # pylint: disable=arguments-differ + def __init_subclass__(cls, model: type[AnyDeclarative] | None = None, **kwargs: Any) -> None: + """Set `__sqla_model__` on type. + + Args: + model: Model represented by the DTO + kwargs: Passed to `super().__init_subclass__()` + """ + super().__init_subclass__(**kwargs) + if model is not None: + cls.__sqla_model__ = model + + def to_mapped(self) -> AnyDeclarative: + """Create an instance of `self.__sqla_model__` + + Fill the bound SQLAlchemy model recursively with values from + this dataclass. + """ + as_model = {} + for pydantic_field in self.__fields__.values(): + value = getattr(self, pydantic_field.name) + if isinstance(value, (list, tuple)): + value = [el.to_mapped() if isinstance(el, FromMapped) else el for el in value] + if isinstance(value, FromMapped): + value = value.to_mapped() + as_model[pydantic_field.name] = value + return cast("AnyDeclarative", self.__sqla_model__(**as_model)) + + @classmethod + def _factory( + cls, name: str, model: type[DeclarativeBase], purpose: Purpose, exclude: set[str] + ) -> type[FromMapped[AnyDeclarative]]: + exclude = set() if exclude is None else exclude + + columns, relationships = _inspect_model(model) + fields: dict[str, tuple[Any, FieldInfo]] = {} + validators: dict[str, AnyClassMethod] = {} + for key, type_hint in get_type_hints(model, localns=_get_localns(model)).items(): + if get_origin(type_hint) is Mapped: + (type_hint,) = get_args(type_hint) + + elem: Column | RelationshipProperty + if key in columns: + elem = columns[key] + elif key in relationships: + elem = relationships[key] + else: + # class var, anything else?? + continue + + dto_field = _get_dto_field(elem) + + if _should_exclude_field(purpose, elem, exclude, dto_field): + continue + + if dto_field.pydantic_type is not None: + type_hint = dto_field.pydantic_type + + for i, func in enumerate(dto_field.validators or []): + validators[f"_validates_{key}_{i}"] = validator(key, allow_reuse=True)(func) + + type_hint = cls._handle_relationships(type_hint, name, purpose) + fields[key] = (type_hint, _construct_field_info(elem, purpose, dto_field)) + + return create_model( # type:ignore[no-any-return,call-overload] + name, + __base__=cls, + __module__=getattr(model, "__module__", __name__), + __validators__=validators, + __cls_kwargs__={"model": model}, + **fields, + ) + + @classmethod + def _handle_relationships(cls, type_hint: Any, name: str, purpose: Purpose) -> Any: + origin_type = get_origin(type_hint) + if origin_type is not None or issubclass(type_hint, DeclarativeBase): + if origin_type: + (type_hint,) = get_args(type_hint) + type_hint = cls._factory( + f"{name}_{type_hint.__name__}", type_hint, purpose=purpose, exclude=set() + ) + if origin_type: + type_hint = origin_type[type_hint] + return type_hint # noqa:R504 + + +def _construct_field_info( + elem: Column | RelationshipProperty, purpose: Purpose, dto_field: DTOField +) -> FieldInfo: + if dto_field.pydantic_field is not None: + return dto_field.pydantic_field + + default = getattr(elem, "default", None) + nullable = getattr(elem, "nullable", False) + if purpose is Purpose.READ: + return FieldInfo(...) + if default is None: + return FieldInfo(default=None) if nullable else FieldInfo(...) + if default.is_scalar: + return FieldInfo(default=default.arg) + if default.is_callable: + return FieldInfo(default_factory=lambda: default.arg({})) + raise ValueError("Unexpected default type") + + +def _get_dto_field(elem: Column | RelationshipProperty) -> DTOField: + return elem.info.get(settings.api.DTO_INFO_KEY, DTOField()) + + +def _should_exclude_field( + purpose: Purpose, elem: Column | RelationshipProperty, exclude: set[str], dto_attrib: DTOField +) -> bool: + if elem.key in exclude: + return True + if dto_attrib.mark is Mark.PRIVATE: + return True + if purpose is Purpose.WRITE and dto_attrib.mark is Mark.READ_ONLY: + return True + return False + + +def _inspect_model( + model: type[DeclarativeBase], +) -> tuple[ReadOnlyColumnCollection[str, Column], ReadOnlyProperties[RelationshipProperty]]: + mapper = cast("Mapper", inspect(model)) + columns = mapper.columns + relationships = mapper.relationships + return columns, relationships + + +def _get_localns(model: type[DeclarativeBase]) -> dict[str, Any]: + model_module = getmodule(model) + return vars(model_module) if model_module is not None else {} diff --git a/src/starlite_saqlalchemy/dto/types.py b/src/starlite_saqlalchemy/dto/types.py new file mode 100644 index 00000000..7ab920cc --- /dev/null +++ b/src/starlite_saqlalchemy/dto/types.py @@ -0,0 +1,83 @@ +"""DTO domain types.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, TypeVar + +from sqlalchemy.orm import DeclarativeBase + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + from typing import Any + + from pydantic.fields import FieldInfo + +__all__ = ( + "AnyDeclarative", + "DTOConfig", + "DTOField", + "Mark", + "Purpose", +) + +AnyDeclarative = TypeVar("AnyDeclarative", bound=DeclarativeBase) + + +class Mark(str, Enum): + """For marking column definitions on the domain models. + + Example: + ```python + class Model(Base): + ... + updated_at: Mapped[datetime] = mapped_column(info={"dto": Mark.READ_ONLY}) + ``` + """ + + READ_ONLY = "read-only" + """To mark a field that can be read, but not updated by clients.""" + PRIVATE = "private" + """To mark a field that can neither be read or updated by clients.""" + + +class Purpose(str, Enum): + """For identifying the purpose of a DTO to the factory. + + The factory will exclude fields marked as private or read-only on the domain model depending + on the purpose of the DTO. + + Example: + ```python + ReadDTO = dto.factory("AuthorReadDTO", Author, purpose=dto.Purpose.READ) + ``` + """ + + READ = "read" + """To mark a DTO that is to be used to serialize data returned to clients.""" + WRITE = "write" + """To mark a DTO that is to deserialize and validate data provided by clients.""" + + +@dataclass +class DTOField: + """For configuring DTO behavior on SQLAlchemy model fields.""" + + mark: Mark | None = None + """Mark the field as read-only, or private.""" + pydantic_type: Any | None = None + """Override the field type on the pydantic model for this attribute.""" + pydantic_field: FieldInfo | None = None + """If provided, used for the pydantic model for this attribute.""" + validators: Iterable[Callable[[Any], Any]] | None = None + """Single argument callables that are defined on the DTO as validators for the field.""" + + +@dataclass +class DTOConfig: + """Control the generated DTO.""" + + purpose: Purpose + """Configure the DTO for "read" or "write" operations.""" + exclude: set[str] = field(default_factory=set) + """Explicitly exclude fields from the generated DTO.""" diff --git a/src/starlite_saqlalchemy/dto/utils.py b/src/starlite_saqlalchemy/dto/utils.py new file mode 100644 index 00000000..608c9dc5 --- /dev/null +++ b/src/starlite_saqlalchemy/dto/utils.py @@ -0,0 +1,59 @@ +"""Things that make working with DTOs nicer.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from starlite_saqlalchemy import settings + +from .types import DTOConfig, DTOField, Mark, Purpose + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + from typing import Any, Literal + + from pydantic.fields import FieldInfo + +__all__ = ( + "config", + "field", +) + + +def config( + purpose: Purpose | Literal["read", "write"], exclude: set[str] | None = None +) -> DTOConfig: + """ + Args: + purpose: Is the DTO for parsing "write" data, or serializing "read" data? + exclude: Omit fields from dto by key name. + + Returns: + `DTOConfig` object configured per parameters. + """ + exclude = set() if exclude is None else exclude + return DTOConfig(purpose=Purpose(purpose), exclude=exclude) + + +def field( + mark: Mark | Literal["read-only", "private"] | None = None, + pydantic_type: Any | None = None, + pydantic_field: FieldInfo | None = None, + validators: Iterable[Callable[[Any], Any]] | None = None, +) -> dict[str, DTOField]: + """Create `dto.DTOField()` wrapped in a dict for SQLAlchemy info field. + + Args: + mark: How this field should be treated by the model factory. + pydantic_type: Override the type annotation for this field. + pydantic_field: Result of Pydantic's `DTOField()` function. Override the `FieldInfo` instance + used by the generated model. + validators: Added to the generated model as validators, with `allow_reuse=True`. + """ + return { + settings.api.DTO_INFO_KEY: DTOField( + mark=Mark(mark) if mark is not None else mark, + pydantic_type=pydantic_type, + pydantic_field=pydantic_field, + validators=validators, + ) + } diff --git a/src/starlite_saqlalchemy/init_plugin.py b/src/starlite_saqlalchemy/init_plugin.py index 16f61b4a..5aad1ecb 100644 --- a/src/starlite_saqlalchemy/init_plugin.py +++ b/src/starlite_saqlalchemy/init_plugin.py @@ -134,7 +134,7 @@ class PluginConfig(BaseModel): do_set_debug: bool = True """ Allow the plugin to set the starlite `debug` parameter. Parameter set to value of - [`AppConfig.debug`][starlite_saqlalchemy.settings.AppConfig.debug]. + [`AppConfig.debug`][starlite_saqlalchemy.settings.AppSettings.DEBUG]. """ do_sqlalchemy_plugin: bool = True """ diff --git a/src/starlite_saqlalchemy/repository/types.py b/src/starlite_saqlalchemy/repository/types.py index fdffad7e..13d901b4 100644 --- a/src/starlite_saqlalchemy/repository/types.py +++ b/src/starlite_saqlalchemy/repository/types.py @@ -1,7 +1,9 @@ """Repository type definitions.""" from __future__ import annotations -from typing import TYPE_CHECKING, Any, Protocol, TypeVar +from typing import Any, TypeVar + +from sqlalchemy.orm import DeclarativeBase from starlite_saqlalchemy.repository.filters import ( BeforeAfter, @@ -9,9 +11,6 @@ LimitOffset, ) -if TYPE_CHECKING: - from pydantic import BaseModel - FilterTypes = BeforeAfter | CollectionFilter[Any] | LimitOffset """Aggregate type alias of the types supported for collection filtering.""" @@ -19,20 +18,4 @@ T = TypeVar("T") -class ModelProtocol(Protocol): # pylint: disable=too-few-public-methods - """Protocol for repository models.""" - - @classmethod - def from_dto(cls: type[T], dto_instance: BaseModel) -> T: # pragma: no cover - """ - - Args: - dto_instance: A pydantic model. - - Returns: - Instance of type with values populated from `dto_instance`. - """ - ... # pylint: disable=unnecessary-ellipsis - - -ModelT = TypeVar("ModelT", bound=ModelProtocol) +ModelT = TypeVar("ModelT", bound=DeclarativeBase) diff --git a/src/starlite_saqlalchemy/settings.py b/src/starlite_saqlalchemy/settings.py index dc4e809d..81637d48 100644 --- a/src/starlite_saqlalchemy/settings.py +++ b/src/starlite_saqlalchemy/settings.py @@ -5,7 +5,7 @@ """ from __future__ import annotations -# pylint: disable=too-few-public-methods,missing-class-docstring +# pylint: disable=missing-class-docstring from typing import Literal from pydantic import AnyUrl, BaseSettings, PostgresDsn, parse_obj_as diff --git a/src/starlite_saqlalchemy/testing.py b/src/starlite_saqlalchemy/testing.py index d8172177..5d32352e 100644 --- a/src/starlite_saqlalchemy/testing.py +++ b/src/starlite_saqlalchemy/testing.py @@ -40,8 +40,6 @@ def __class_getitem__(cls: type[MockRepoT], item: type[ModelT]) -> type[MockRepo Args: item: The type that the class has been parametrized with. - - Returns: """ cls._collections.setdefault(item, {}) return cls @@ -169,9 +167,5 @@ def seed_collection(cls, instances: Iterable[ModelT]) -> None: @classmethod def clear_collection(cls) -> None: - """Empty the collection for repository type. - - Args: - type_: type for which collection should be cleared. - """ + """Empty the collection for repository type.""" cls._collections[cls.model_type] = {} diff --git a/tests/unit/test_dto.py b/tests/unit/test_dto.py index da97d357..54e9845d 100644 --- a/tests/unit/test_dto.py +++ b/tests/unit/test_dto.py @@ -1,15 +1,22 @@ """Tests for the dto factory.""" -# pylint: disable=redefined-outer-name,too-few-public-methods,missing-class-docstring -from datetime import date, datetime -from typing import TYPE_CHECKING, Any, ClassVar +# pylint: disable=missing-class-docstring,invalid-name +from datetime import date, datetime, timedelta +from typing import TYPE_CHECKING, Annotated, Any, ClassVar from uuid import UUID, uuid4 import pytest -from sqlalchemy import func -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from pydantic import Field, constr, validator +from sqlalchemy import ForeignKey, func +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + MappedAsDataclass, + mapped_column, + relationship, +) from starlite_saqlalchemy import dto, settings -from tests.utils.domain.authors import Author +from tests.utils.domain.authors import Author, WriteDTO if TYPE_CHECKING: from collections.abc import Callable @@ -18,7 +25,7 @@ def test_model_write_dto(raw_authors: list[dict[str, Any]]) -> None: """Create a model from DTO instance and check the values on the model.""" - dto_type = dto.factory("AuthorDTO", Author, dto.Purpose.WRITE) + dto_type = dto.FromMapped[Annotated[Author, dto.config("write")]] assert dto_type.__fields__.keys() == {"name", "dob"} inst = dto_type(**raw_authors[0]) model = Author(**inst.dict(exclude_unset=True)) @@ -30,7 +37,7 @@ def test_model_write_dto(raw_authors: list[dict[str, Any]]) -> None: def test_model_read_dto(raw_authors: list[dict[str, Any]]) -> None: """Create a model from DTO instance and check the values on the model.""" - dto_type = dto.factory("AuthorDTO", Author, dto.Purpose.READ) + dto_type = dto.FromMapped[Annotated[Author, dto.config("read")]] assert dto_type.__fields__.keys() == {"name", "dob", "id", "created", "updated"} inst = dto_type(**raw_authors[1]) model = Author(**inst.dict(exclude_unset=True)) @@ -45,12 +52,12 @@ def test_model_read_dto(raw_authors: list[dict[str, Any]]) -> None: def test_dto_exclude() -> None: """Test that names in `exclude` are not included in DTO.""" - dto_type = dto.factory("AuthorDTO", Author, dto.Purpose.READ, exclude={"id"}) + dto_type = dto.FromMapped[Annotated[Author, dto.config("read", {"id"})]] assert dto_type.__fields__.keys() == {"name", "dob", "created", "updated"} -@pytest.fixture() -def base() -> type[DeclarativeBase]: +@pytest.fixture(name="base") +def fx_base() -> type[DeclarativeBase]: """Declarative base for test models. Need a new base for every test, otherwise will get errors to do with @@ -76,7 +83,7 @@ class Model(base): __tablename__ = "smth" field: Mapped[int] = mapped_column(default=default) - dto_model = dto.factory("DTO", Model, purpose=purpose) + dto_model = dto.FromMapped[Annotated[Model, dto.config(purpose)]] assert dto_model.__fields__["field"].default == exp @@ -87,8 +94,7 @@ class Model(base): __tablename__ = "smth" field: Mapped[UUID] = mapped_column(default=uuid4) - dto_model = dto.factory("DTO", Model, purpose=dto.Purpose.WRITE) - + dto_model = dto.FromMapped[Annotated[Model, dto.config("write")]] assert dto_model.__fields__["field"].default_factory is not None assert isinstance(dto_model.__fields__["field"].default_factory(), UUID) @@ -100,8 +106,7 @@ class Model(base): __tablename__ = "smth" field: Mapped[UUID] = mapped_column(default=uuid4) - dto_model = dto.factory("DTO", Model, purpose=dto.Purpose.READ) - + dto_model = dto.FromMapped[Annotated[Model, dto.config("read")]] assert dto_model.__fields__["field"].default_factory is None @@ -114,21 +119,24 @@ class Model(base): field: Mapped[datetime] = mapped_column(default=func.now()) with pytest.raises(ValueError): # noqa: PT011 - dto.factory("DTO", Model, purpose=dto.Purpose.WRITE) + # noinspection PyStatementEffect + dto.FromMapped[ # pylint: disable=expression-not-assigned + Annotated[Model, dto.config("write")] + ] @pytest.mark.parametrize("purpose", [dto.Purpose.WRITE, dto.Purpose.READ]) def test_dto_for_private_model_field(purpose: dto.Purpose, base: type[DeclarativeBase]) -> None: - """Ensure that fields markets as SKIP are excluded from DTO.""" + """Ensure that fields markets as PRIVATE are excluded from DTO.""" class Model(base): __tablename__ = "smth" field: Mapped[datetime] = mapped_column( default=datetime.now(), - info={settings.api.DTO_INFO_KEY: dto.Attrib(mark=dto.Mark.SKIP)}, + info={settings.api.DTO_INFO_KEY: dto.DTOField(mark=dto.Mark.PRIVATE)}, ) - dto_model = dto.factory("DTO", Model, purpose=purpose) + dto_model = dto.FromMapped[Annotated[Model, dto.config(purpose)]] assert "field" not in dto_model.__fields__ @@ -140,7 +148,7 @@ class Model(base): __tablename__ = "smth" field: ClassVar[datetime] - dto_model = dto.factory("DTO", Model, purpose=purpose) + dto_model = dto.FromMapped[Annotated[Model, dto.config(purpose)]] assert "field" not in dto_model.__fields__ @@ -165,5 +173,143 @@ class Test(orm.Base): ) model = module.Test assert all(isinstance(model.__annotations__[k], str) for k in ("hello", "related")) - dto_model = dto.factory("TestDTO", module.Test, purpose=dto.Purpose.READ) + dto_model = dto.FromMapped[Annotated[model, dto.config("read")]] assert all(not isinstance(dto_model.__annotations__[k], str) for k in ("hello", "related")) + + +def test_subclassed_dto() -> None: + """Test dto subclass decoration. + + Test ensures that fields defined on the subclass overwrite those + generated by factory(), that fields not defined on the subclass are + added to the DTO, and that validators work for fields that are added + both statically, and dynamically (with the `check_fields=False` + flag). + """ + + class AuthorDTO(dto.FromMapped[Annotated[Author, "write"]]): + name: constr(to_upper=True) # pyright:ignore + + @validator("name") + def validate_name(cls, val: str) -> str: + """We're shouting!""" + return f"{val}!" + + @validator("dob", check_fields=False) + def validate_dob(cls, val: date) -> date: + """Off by one.""" + val += timedelta(days=1) + return val + + assert AuthorDTO.parse_obj({"name": "Bill Bryson", "dob": "1951-12-08"}).dict() == { + "name": "BILL BRYSON!", + "dob": date(1951, 12, 9), + } + + +def test_dto_attrib_validator(base: type[DeclarativeBase]) -> None: + """Test arbitrary single arg callables as validators.""" + + validator_called = False + + def validate_datetime(val: datetime) -> datetime: + nonlocal validator_called + validator_called = True + return val + + class Model(base): + __tablename__ = "smth" + field: Mapped[datetime] = mapped_column( + info={settings.api.DTO_INFO_KEY: dto.DTOField(validators=[validate_datetime])} + ) + + dto_model = dto.FromMapped[Annotated[Model, dto.config("write")]] + dto_model.parse_obj({"id": 1, "field": datetime.min}) + assert validator_called + + +def test_dto_attrib_pydantic_type(base: type[DeclarativeBase]) -> None: + """Test declare pydantic type on `dto.DTOField`.""" + + class Model(base): + __tablename__ = "smth" + field: Mapped[str] = mapped_column( + info={settings.api.DTO_INFO_KEY: dto.DTOField(pydantic_type=constr(to_upper=True))} + ) + + dto_model = dto.FromMapped[Annotated[Model, dto.config("write")]] + assert dto_model.parse_obj({"id": 1, "field": "lower"}).dict() == {"id": 1, "field": "LOWER"} + + +def test_dto_mapped_as_dataclass_model_type(base: type[DeclarativeBase]) -> None: + """Test declare pydantic type on `dto.DTOField`.""" + + class Model(MappedAsDataclass, base): + __tablename__ = "smth" + clz_var: ClassVar[str] + field: Mapped[str] + + dto_model = dto.FromMapped[Annotated[Model, dto.config("write")]] + assert dto_model.__fields__.keys() == {"id", "field"} + + +def test_from_dto() -> None: + """Test conversion of a DTO instance to a model instance.""" + data = WriteDTO.parse_obj({"name": "someone", "dob": "1982-03-22"}) + author = data.to_mapped() + assert author.name == "someone" + assert author.dob == date(1982, 3, 22) + + +def test_invalid_from_mapped_annotation() -> None: + """Test error raised if from mapped called without Annotated.""" + with pytest.raises(ValueError): # noqa:PT011 + dto.FromMapped[Author] # pylint: disable=pointless-statement + + +def test_to_mapped_model_with_collection_relationship(base: type[DeclarativeBase]) -> None: + """Test building a DTO with collection relationship, and parsing data.""" + + class A(base): + __tablename__ = "a" + b_id: Mapped[int] = mapped_column(ForeignKey("b.id")) + + class B(base): + __tablename__ = "b" + + a: Mapped[list[A]] = relationship("A") + + DTO = dto.FromMapped[Annotated[B, "write"]] + dto_instance = DTO.parse_obj({"id": 1, "a": [{"id": 2, "b_id": 1}, {"id": 3, "b_id": 1}]}) + mapped_instance = dto_instance.to_mapped() + assert len(mapped_instance.a) == 2 + assert all(isinstance(val, A) for val in mapped_instance.a) + + +def test_to_mapped_model_with_scalar_relationship(base: type[DeclarativeBase]) -> None: + """Test building DTO with Scalar relationship, and parsing data.""" + + class A(base): + __tablename__ = "a" + + class B(base): + __tablename__ = "b" + a_id: Mapped[int] = mapped_column(ForeignKey("a.id"), info=dto.field("private")) + a: Mapped[A] = relationship("A") + + DTO = dto.FromMapped[Annotated[B, "write"]] + dto_instance = DTO.parse_obj({"id": 2, "a": {"id": 1}}) + mapped_instance = dto_instance.to_mapped() + assert isinstance(mapped_instance.a, A) + + +def test_dto_field_pydantic_field(base: type[DeclarativeBase]) -> None: + """Test specifying DTOField.pydantic_field.""" + + class A(base): + __tablename__ = "a" + val: Mapped[int] = mapped_column(info=dto.field(pydantic_field=Field(le=1))) + + DTO = dto.FromMapped[Annotated[A, "write"]] + with pytest.raises(ValueError): # noqa:PT011 + DTO.parse_obj({"id": 1, "val": 2}) diff --git a/tests/unit/test_endpoint_decorator.py b/tests/unit/test_endpoint_decorator.py index ac693d05..5946db5f 100644 --- a/tests/unit/test_endpoint_decorator.py +++ b/tests/unit/test_endpoint_decorator.py @@ -1,5 +1,4 @@ """Tests for endpoint_decorator.py.""" -# pylint: disable=too-few-public-methods from __future__ import annotations import pytest diff --git a/tests/unit/test_orm.py b/tests/unit/test_orm.py index d98dc597..52ebb942 100644 --- a/tests/unit/test_orm.py +++ b/tests/unit/test_orm.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock from starlite_saqlalchemy.db import orm -from tests.utils.domain.authors import Author, CreateDTO def test_sqla_touch_updated_timestamp() -> None: @@ -13,11 +12,3 @@ def test_sqla_touch_updated_timestamp() -> None: orm.touch_updated_timestamp(mock_session) for mock_instance in mock_session.dirty: assert isinstance(mock_instance.updated, datetime.datetime) - - -def test_from_dto() -> None: - """Test conversion of a DTO instance to a model instance.""" - data = CreateDTO(name="someone", dob="1982-03-22") - author = Author.from_dto(data) - assert author.name == "someone" - assert author.dob == datetime.date(1982, 3, 22) diff --git a/tests/utils/controllers.py b/tests/utils/controllers.py index 5e86f262..d2898450 100644 --- a/tests/utils/controllers.py +++ b/tests/utils/controllers.py @@ -8,7 +8,7 @@ from starlite.status_codes import HTTP_200_OK from starlite_saqlalchemy.repository.types import FilterTypes -from tests.utils.domain.authors import Author, CreateDTO, ReadDTO, Service, UpdateDTO +from tests.utils.domain.authors import ReadDTO, Service, WriteDTO DETAIL_ROUTE = "/{author_id:uuid}" @@ -28,9 +28,9 @@ async def get_authors( @post() -async def create_author(data: CreateDTO, service: Service) -> ReadDTO: +async def create_author(data: WriteDTO, service: Service) -> ReadDTO: """Create an `Author`.""" - return ReadDTO.from_orm(await service.create(Author.from_dto(data))) + return ReadDTO.from_orm(await service.create(data.to_mapped())) @get(DETAIL_ROUTE) @@ -40,9 +40,9 @@ async def get_author(service: Service, author_id: UUID) -> ReadDTO: @put(DETAIL_ROUTE) -async def update_author(data: UpdateDTO, service: Service, author_id: UUID) -> ReadDTO: +async def update_author(data: WriteDTO, service: Service, author_id: UUID) -> ReadDTO: """Update an author.""" - return ReadDTO.from_orm(await service.update(author_id, Author.from_dto(data))) + return ReadDTO.from_orm(await service.update(author_id, data.to_mapped())) @delete(DETAIL_ROUTE, status_code=HTTP_200_OK) diff --git a/tests/utils/domain/authors.py b/tests/utils/domain/authors.py index 0ed056e6..353c4e81 100644 --- a/tests/utils/domain/authors.py +++ b/tests/utils/domain/authors.py @@ -2,13 +2,14 @@ from __future__ import annotations from datetime import date +from typing import Annotated from sqlalchemy.orm import Mapped from starlite_saqlalchemy import db, dto, repository, service -class Author(db.orm.Base): # pylint: disable=too-few-public-methods +class Author(db.orm.Base): """The Author domain object.""" name: Mapped[str] @@ -27,15 +28,8 @@ class Service(service.Service[Author]): repository_type = Repository -CreateDTO = dto.factory("AuthorCreateDTO", Author, purpose=dto.Purpose.WRITE, exclude={"id"}) -""" -A pydantic model to validate `Author` creation data. -""" -ReadDTO = dto.factory("AuthorReadDTO", Author, purpose=dto.Purpose.READ) -""" -A pydantic model to serialize outbound `Author` representations. -""" -UpdateDTO = dto.factory("AuthorUpdateDTO", Author, purpose=dto.Purpose.WRITE) -""" -A pydantic model to validate and deserialize `Author` update data. -""" +ReadDTO = dto.FromMapped[Annotated[Author, "read"]] +"""A pydantic model to serialize outbound `Author` representations.""" + +WriteDTO = dto.FromMapped[Annotated[Author, "write"]] +"""A pydantic model to validate and deserialize `Author` update data.""" diff --git a/tests/utils/domain/books.py b/tests/utils/domain/books.py index 771b058e..b33589c3 100644 --- a/tests/utils/domain/books.py +++ b/tests/utils/domain/books.py @@ -1,6 +1,7 @@ """Books domain definitions.""" from __future__ import annotations +from typing import Annotated from uuid import UUID from sqlalchemy import ForeignKey @@ -17,7 +18,7 @@ class Book(db.orm.Base): # pylint: disable=too-few-public-methods title: Mapped[str] author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id")) author: Mapped[Author] = relationship( - lazy="joined", innerjoin=True, info={"dto": dto.Attrib(mark=dto.Mark.READ_ONLY)} + lazy="joined", innerjoin=True, info={"dto": dto.DTOField(mark=dto.Mark.READ_ONLY)} ) @@ -33,11 +34,11 @@ class Service(service.Service[Book]): repository_type = Repository -ReadDTO = dto.factory("BookReadDTO", Book, purpose=dto.Purpose.READ) +ReadDTO = dto.FromMapped[Annotated[Book, "read"]] """ A pydantic model to serialize outbound `Book` representations. """ -WriteDTO = dto.factory("BookWriteDTO", Book, purpose=dto.Purpose.WRITE) +WriteDTO = dto.FromMapped[Annotated[Book, "write"]] """ A pydantic model to validate and deserialize `Book` creation/update data. """ diff --git a/tox.ini b/tox.ini index de21dace..a70b4aa5 100644 --- a/tox.ini +++ b/tox.ini @@ -37,7 +37,7 @@ deps = pylint {[testenv]deps} commands = - python -m pylint examples/ src/ tests/ + python -m pylint src/ tests/ [testenv:mypy] basepython = python3.11