Skip to content

Commit

Permalink
Merge 92fca49 into b02953a
Browse files Browse the repository at this point in the history
  • Loading branch information
grigi committed Mar 17, 2020
2 parents b02953a + 92fca49 commit 4c86509
Show file tree
Hide file tree
Showing 16 changed files with 325 additions and 19 deletions.
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ omit =
tortoise/contrib/quart/*
tortoise/contrib/sanic/*
tortoise/contrib/starlette/*
tortoise/contrib/fastapi/*
[report]
show_missing = True
9 changes: 9 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ Changelog
* ``maxLength`` for CharFields
* ``minimum`` & ``maximum`` values for integer fields

To get Pydantic to handle nullable/defaulted fields correctly one should do a ``**user.dict(exclude_unset=True)`` when passing values to a Model class.

* Added ``FastAPI`` helper that is based on the ``starlette`` helper but optionally adds helpers to catch and report with proper error ``DoesNotExist`` and ``IntegrityError`` Tortoise exceptions.
* Allows a Pydantic model to exclude all read-only fields by setting ``exclude_readonly=True`` when calling ``pydantic_model_creator``.
* a Tortoise ``PydanticModel`` now provides two extra helper functions:

* ``from_queryset``: Returns a ``List[PydanticModel]`` which is the format that e.g. FastAPI expects
* ``from_queryset_single``: allows one to avoid calling ``await`` multiple times to get the object and all its related items.


0.16.0
------
Expand Down
3 changes: 2 additions & 1 deletion docs/contrib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ Contrib
.. toctree::
:maxdepth: 3

contrib/pydantic
contrib/linters
contrib/pydantic
contrib/unittest
contrib/fastapi
contrib/quart
contrib/sanic
contrib/starlette
Expand Down
20 changes: 20 additions & 0 deletions docs/contrib/fastapi.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.. _contrib_fastapi:

================================
Tortoise-ORM FastAPI integration
================================

We have a lightweight integration util ``tortoise.contrib.fastapi`` which has a single function ``register_tortoise`` which sets up Tortoise-ORM on startup and cleans up on teardown.

FastAPI is basically Starlette + Pydantic, but in a very specific way.
Document LOTS MORE!

See the :ref:`example_fastapi`

Reference
=========

.. automodule:: tortoise.contrib.fastapi
:members:
:undoc-members:
:show-inheritance:
1 change: 1 addition & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Examples

examples/basic
examples/pydantic
examples/fastapi
examples/quart
examples/sanic
examples/starlette
22 changes: 22 additions & 0 deletions docs/examples/fastapi.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.. _example_fastapi:

===============
FastAPI Example
===============

This is an example of the :ref:`contrib_fastapi`

**Usage:**

.. code-block:: sh
uvicorn main:app --reload
models.py
=========
.. literalinclude:: ../../examples/fastapi/models.py

main.py
=======
.. literalinclude:: ../../examples/fastapi/main.py
1 change: 1 addition & 0 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ quart
sanic
starlette
pydantic
fastapi
11 changes: 11 additions & 0 deletions examples/fastapi/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Tortoise-ORM FastAPI example
============================

We have a lightweight integration util ``tortoise.contrib.fastapi`` which has a single function ``register_tortoise`` which sets up Tortoise-ORM on startup and cleans up on teardown.

Usage
-----

.. code-block:: sh
uvicorn main:app --reload
Empty file added examples/fastapi/__init__.py
Empty file.
53 changes: 53 additions & 0 deletions examples/fastapi/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# pylint: disable=E0611
from typing import List

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

from models import User_Pydantic, UserIn_Pydantic, Users
from tortoise.contrib.fastapi import HTTPNotFoundError, register_tortoise

app = FastAPI(title="Tortoise ORM FastAPI example")


class Status(BaseModel):
message: str


@app.get("/users", response_model=List[User_Pydantic])
async def get_users():
return await User_Pydantic.from_queryset(Users.all())


@app.post("/users", response_model=User_Pydantic)
async def create_user(user: UserIn_Pydantic):
user_obj = await Users.create(**user.dict(exclude_unset=True))
return await User_Pydantic.from_tortoise_orm(user_obj)


@app.get(
"/user/{user_id}", response_model=User_Pydantic, responses={404: {"model": HTTPNotFoundError}}
)
async def get_user(user_id: int):
return await User_Pydantic.from_queryset_single(Users.get(id=user_id))


@app.post(
"/user/{user_id}", response_model=User_Pydantic, responses={404: {"model": HTTPNotFoundError}}
)
async def update_user(user_id: int, user: UserIn_Pydantic):
await Users.filter(id=user_id).update(**user.dict(exclude_unset=True))
return await User_Pydantic.from_queryset_single(Users.get(id=user_id))


@app.delete("/user/{user_id}", response_model=Status, responses={404: {"model": HTTPNotFoundError}})
async def delete_user(user_id: int):
deleted_count = await Users.filter(id=user_id).delete()
if not deleted_count:
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
return Status(message=f"Deleted user {user_id}")


register_tortoise(
app, db_url="sqlite://:memory:", modules={"models": ["models"]}, generate_schemas=True
)
40 changes: 40 additions & 0 deletions examples/fastapi/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from tortoise import fields, models
from tortoise.contrib.pydantic import pydantic_model_creator


class Users(models.Model):
"""
The User model
"""

id = fields.IntField(pk=True)
#: This is a username
username = fields.CharField(max_length=20, unique=True)
name = fields.CharField(max_length=50, null=True)
family_name = fields.CharField(max_length=50, null=True)
category = fields.CharField(max_length=30, default="misc")
password_hash = fields.CharField(max_length=128, null=True)
created_at = fields.DatetimeField(auto_now_add=True)
modified_at = fields.DatetimeField(auto_now=True)

def full_name(self) -> str:
"""
Returns the best name
"""
if self.name or self.family_name:
return f"{self.name or ''} {self.family_name or ''}".strip()
return self.username

def set_password(self, password: str, repeat_password: str) -> bool:
"""
Sets the password_hash
"""
pass

class PydanticMeta:
computed = ["full_name"]
exclude = ["password_hash"]


User_Pydantic = pydantic_model_creator(Users, name="User")
UserIn_Pydantic = pydantic_model_creator(Users, name="UserIn", exclude_readonly=True)
3 changes: 3 additions & 0 deletions tests/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,6 @@ uvicorn

# Pydantic support
pydantic

# FastAPI support
fastapi
5 changes: 3 additions & 2 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ docopt==0.6.2 # via coveralls
docutils==0.14 # via -r ../docs/requirements.in, -r requirements.in, readme-renderer, sphinx
entrypoints==0.3 # via flake8
execnet==1.7.1 # via pytest-xdist
fastapi==0.52.0 # via -r requirements.in
filelock==3.0.12 # via tox, virtualenv
flake8-comprehensions==3.2.2 # via -r requirements.in
flake8-isort==2.9.0 # via -r requirements.in
Expand Down Expand Up @@ -77,7 +78,7 @@ priority==1.3.0 # via hypercorn
py==1.8.1 # via pytest, tox
pycodestyle==2.5.0 # via flake8
pycparser==2.20 # via cffi
pydantic==1.4 # via -r requirements.in
pydantic==1.4 # via -r requirements.in, fastapi
pyflakes==2.1.1 # via flake8
pygments==2.5.1 # via -r ../docs/requirements.in, -r requirements.in, readme-renderer, sphinx
pylint==2.4.4 # via -r requirements.in
Expand All @@ -104,7 +105,7 @@ sniffio==1.1.0 # via httpx
snowballstemmer==2.0.0 # via sphinx
sphinx==1.8.5 # via -r ../docs/requirements.in, cloud-sptheme
sphinxcontrib-websupport==1.2.0 # via sphinx
starlette==0.13.2 # via -r requirements.in
starlette==0.13.2 # via -r requirements.in, fastapi
stevedore==1.32.0 # via bandit
testfixtures==6.14.0 # via flake8-isort
toml==0.10.0 # via black, hypercorn, isort, quart, tox
Expand Down
113 changes: 113 additions & 0 deletions tortoise/contrib/fastapi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import logging
from typing import Dict, List, Optional

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel # pylint: disable=E0611

from tortoise import Tortoise
from tortoise.exceptions import DoesNotExist, IntegrityError


class HTTPNotFoundError(BaseModel):
detail: str


def register_tortoise(
app: FastAPI,
config: Optional[dict] = None,
config_file: Optional[str] = None,
db_url: Optional[str] = None,
modules: Optional[Dict[str, List[str]]] = None,
generate_schemas: bool = False,
add_exception_handlers: bool = True,
) -> None:
"""
Registers ``startup`` and ``shutdown`` events to set-up and tear-down Tortoise-ORM
inside a FastAPI application.
You can configure using only one of ``config``, ``config_file``
and ``(db_url, modules)``.
Parameters
----------
app:
FastAPI app.
config:
Dict containing config:
Example
-------
.. code-block:: python3
{
'connections': {
# Dict format for connection
'default': {
'engine': 'tortoise.backends.asyncpg',
'credentials': {
'host': 'localhost',
'port': '5432',
'user': 'tortoise',
'password': 'qwerty123',
'database': 'test',
}
},
# Using a DB_URL string
'default': 'postgres://postgres:qwerty123@localhost:5432/events'
},
'apps': {
'models': {
'models': ['__main__'],
# If no default_connection specified, defaults to 'default'
'default_connection': 'default',
}
}
}
config_file:
Path to .json or .yml (if PyYAML installed) file containing config with
same format as above.
db_url:
Use a DB_URL string. See :ref:`db_url`
modules:
Dictionary of ``key``: [``list_of_modules``] that defined "apps" and modules that
should be discovered for models.
generate_schemas:
True to generate schema immediately. Only useful for dev environments
or SQLite ``:memory:`` databases
add_exception_handlers:
Should we add exception handlers for ``DoesNotExist`` & ``IntegrityError``?
Raises
------
ConfigurationError
For any configuration error
"""

@app.on_event("startup")
async def init_orm() -> None: # pylint: disable=W0612
await Tortoise.init(config=config, config_file=config_file, db_url=db_url, modules=modules)
logging.info("Tortoise-ORM started, %s, %s", Tortoise._connections, Tortoise.apps)
if generate_schemas:
logging.info("Tortoise-ORM generating schema")
await Tortoise.generate_schemas()

@app.on_event("shutdown")
async def close_orm() -> None: # pylint: disable=W0612
await Tortoise.close_connections()
logging.info("Tortoise-ORM shutdown")

if add_exception_handlers:

@app.exception_handler(DoesNotExist)
async def doesnotexist_exception_handler(request: Request, exc: DoesNotExist):
return JSONResponse(status_code=404, content={"detail": str(exc)})

@app.exception_handler(IntegrityError)
async def integrityerror_exception_handler(request: Request, exc: IntegrityError):
return JSONResponse(
status_code=422,
content={"detail": {"loc": [], "msg": str(exc), "type": "IntegrityError"}},
)
29 changes: 28 additions & 1 deletion tortoise/contrib/pydantic/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

if TYPE_CHECKING: # pragma: nocoverage
from tortoise.models import Model
from tortoise.queryset import QuerySet
from tortoise.queryset import QuerySet, QuerySetSingle


def _get_fetch_fields(
Expand All @@ -24,6 +24,7 @@ def _get_fetch_fields(
origin = getattr(field_type, "__origin__", None)
if origin in (list, List, Union):
field_type = field_type.__args__[0]

# noinspection PyProtectedMember
if field_name in model_class._meta.fetch_fields and issubclass(field_type, PydanticModel):
subclass_fetch_fields = _get_fetch_fields(
Expand Down Expand Up @@ -87,6 +88,32 @@ async def from_tortoise_orm(cls, obj: "Model") -> "PydanticModel":
values = super().from_orm(obj)
return values

@classmethod
async def from_queryset_single(cls, queryset: "QuerySetSingle") -> "PydanticModel":
"""
Returns a serializable pydantic model instance that contains a list of models,
from the provided queryset.
This will prefetch all the relations automatically.
:param queryset: a queryset on the model this PydanticListModel is based on.
"""
fetch_fields = _get_fetch_fields(cls, getattr(cls.__config__, "orig_model"))
return cls.from_orm(await queryset.prefetch_related(*fetch_fields))

@classmethod
async def from_queryset(cls, queryset: "QuerySet") -> "List[PydanticModel]":
"""
Returns a serializable pydantic model instance that contains a list of models,
from the provided queryset.
This will prefetch all the relations automatically.
:param queryset: a queryset on the model this PydanticListModel is based on.
"""
fetch_fields = _get_fetch_fields(cls, getattr(cls.__config__, "orig_model"))
return [cls.from_orm(e) for e in await queryset.prefetch_related(*fetch_fields)]


class PydanticListModel(BaseModel):
"""
Expand Down

0 comments on commit 4c86509

Please sign in to comment.