Skip to content

Commit

Permalink
Merge pull request #200 from gmcrocetti/pydantic-support
Browse files Browse the repository at this point in the history
Add Pydantic support
  • Loading branch information
prkumar authored Sep 10, 2020
2 parents 4c9a380 + 07edb49 commit 2f4260d
Show file tree
Hide file tree
Showing 11 changed files with 339 additions and 6 deletions.
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ Contributors
- Alexander Duryagin (`@daa <https://github.com/daa>`_)
- Sakorn Waungwiwatsin (`@SakornW <https://github.com/SakornW>`_)
- Jacob Floyd (`@cognifloyd <https://github.com/cognifloyd>`_)
- Guilherme Crocetti (`@gmcrocetti <https://github.com/gmcrocetti/>`_)
2 changes: 1 addition & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ Sphinx = {version = "*", markers = "python_version != '3.3'"}
sphinx-autobuild = {version = "*", markers = "python_version != '3.3'"}

[packages]
"e1839a8" = {path = ".", extras = ["tests", "aiohttp", "marshmallow", "twisted", "typing"], editable = true}
"e1839a8" = {editable = true, extras = ["aiohttp", "marshmallow", "pydantic", "tests", "twisted", "typing"], path = "."}
7 changes: 4 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Features

- Define `custom converters`_ for your own objects
- Support for |marshmallow|_ schemas and `handling collections`_ (e.g., list of Users)
- Support for pydantic models and :ref:`handling collections <converting_collections>` (e.g., list of Repos)

- **Extendable**

Expand Down Expand Up @@ -114,7 +115,7 @@ If you are interested in the cutting-edge, preview the upcoming release with:
Extra! Extra!
-------------

Further, uplink has optional integrations and features. You can view a full list
Further, uplink has optional integrations and features. You can view a full list
of available extras `here <http://uplink.readthedocs.io/en/latest/install.html#extras>`_.

When installing Uplink with ``pip``, you can select extras using the format:
Expand Down Expand Up @@ -188,8 +189,8 @@ Thank you for taking the time to improve an open source project :purple_heart:
.. |Code Style| image:: https://img.shields.io/badge/code%20style-black-000000.svg
:target: https://github.com/ambv/black
:alt: Code style: black
.. |Coverage Status| image:: https://img.shields.io/codecov/c/github/prkumar/uplink.svg
:alt: Codecov
.. |Coverage Status| image:: https://img.shields.io/codecov/c/github/prkumar/uplink.svg
:alt: Codecov
:target: https://codecov.io/gh/prkumar/uplink
.. |Documentation Status| image:: https://readthedocs.org/projects/uplink/badge/?version=latest
:target: http://uplink.readthedocs.io/en/latest/?badge=latest
Expand Down
15 changes: 15 additions & 0 deletions docs/source/dev/converters.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ Uplink comes with optional support for :py:mod:`marshmallow`.
included if you have :py:mod:`marshmallow` installed, so you don't need
to provide it when constructing your consumer instances.

Pydantic
===========

.. versionadded:: v0.9.2

Uplink comes with optional support for :py:mod:`pydantic`.

.. autoclass:: uplink.converters.PydanticConverter

.. note::

Starting with version v0.9.2, this converter factory is automatically
included if you have :py:mod:`pydantic` installed, so you don't need
to provide it when constructing your consumer instances.

.. _`converting lists and mappings`:

Converting Collections
Expand Down
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Features

- Define :ref:`custom converters <custom_json_deserialization>` for your own objects
- Support for |marshmallow|_ schemas and :ref:`handling collections <converting_collections>` (e.g., list of Users)
- Support for pydantic models and :ref:`handling collections <converting_collections>` (e.g., list of Repos)

- **Extendable**

Expand Down
5 changes: 4 additions & 1 deletion docs/source/user/install.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ Extra Description
for `converting JSON responses directly into Python objects
<https://github.com/prkumar/uplink/tree/master/examples/marshmallow>`_
using :py:class:`marshmallow.Schema`.
``pydantic`` Enables :py:class:`uplink.PydanticConverter`,
for converting JSON responses directly into Python objects
using :py:class:`pydantic.BaseModel`.
``twisted`` Enables :py:class:`uplink.TwistedClient`,
for `sending non-blocking requests <https://github.com/prkumar/uplink/tree/master/examples/async-requests>`_ and receiving
:py:class:`~twisted.internet.defer.Deferred` responses.
Expand All @@ -62,5 +65,5 @@ To download all available features, run

::

$ pip install -U uplink[aiohttp, marshmallow, twisted]
$ pip install -U uplink[aiohttp, marshmallow, pydantic, twisted]

88 changes: 87 additions & 1 deletion docs/source/user/serialization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ dealing with the underlying protocol.

This document walks you through how to leverage Uplink's serialization support,
including integrations for third-party serialization libraries like
:mod:`marshmallow` and tools for writing custom conversion strategies that
:mod:`marshmallow`, :mod:`pydantic` and tools for writing custom conversion strategies that
fit your unique needs.

.. _using_marshmallow_schemas:
Expand Down Expand Up @@ -79,6 +79,71 @@ schema:
For a more complete example of Uplink's :mod:`marshmallow` support,
check out `this example on GitHub <https://github.com/prkumar/uplink/tree/master/examples/marshmallow>`_.

.. _using_pydantic_schemas:

Using Pydantic Models
=========================

:mod:`pydantic` is a framework-agnostic, object serialization library
for Python >= 3.6. Uplink comes with built-in support for Pydantic; you can
integrate your Pydantic models with Uplink for easy JSON (de)serialization.

First, create a :class:`pydantic.BaseModel`, declaring any necessary
conversions and validations. Here's a simple example:

.. code-block:: python
from typing import List
from pydantic import BaseModel, HttpUrl
class Owner(BaseModel):
id: int
avatar_url: HttpUrl
organizations_url: HttpUrl
class Repo(BaseModel):
id: int
full_name: str
owner: Owner
Then, specify the schema using the :class:`@returns <uplink.returns>` decorator:

.. code-block:: python
:emphasize-lines: 2
class GitHub(Consumer):
@returns.json(List[Repo])
@get("users/{username}/repos")
def get_repos(self, username):
"""Get the user's public repositories."""
Python 3 users can use a return type hint instead:

.. code-block:: python
:emphasize-lines: 3
class GitHub(Consumer):
@returns.json()
@get("users/{username}/repos")
def get_repos(self, username) -> List[Repo]:
"""Get the user's public repositories."""
Your consumer should now return Python objects based on your Pydantic
model:

.. code-block:: python
github = GitHub(base_url="https://api.github.com")
print(github.get_repos("octocat"))
# Output: [User(id=132935648, full_name='octocat/boysenberry-repo-1', owner=Owner(...), ...]
.. note::

You may have noticed the usage of `returns.json` in both examples. Unlike :mod:`marshmallow`, :mod:`pydantic`
has no `many` parameter to control the deserialization of multiple objects. The recommended approach
is to use `returns.json` instead of defining a new model with a `__root__` element.

Serializing Method Arguments
============================

Expand Down Expand Up @@ -111,6 +176,27 @@ Uplink's :mod:`marshmallow` integration (see
repo = Repo(name="my_favorite_new_project")
github.create_repo(repo)
The sample code above using :mod:`marshmallow` is also reproducible using :mod:`pydantic`:

.. code-block:: python
from uplink import Consumer, Body
class CreateRepo(BaseModel):
name: str
delete_branch_on_merge: bool
class GitHub(Consumer):
@post("user/repos")
def create_repo(self, repo: Body(type=CreateRepo)):
"""Creates a new repository for the authenticated user."""
Then, calling the client.

.. code-block:: python
repo = CreateRepo(name="my-new-uplink-pydantic", delete_branch_on_merge=True)
github.create_repo(repo)
.. _custom_json_deserialization:

Custom JSON Conversion
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def read(filename):

extras_require = {
"marshmallow": ["marshmallow>=2.15.0"],
"pydantic:python_version >= '3.6'": ["pydantic>=1.6.1"],
"aiohttp:python_version <= '3.4'": [],
"aiohttp:python_version >= '3.4'": "aiohttp>=2.3.0",
"twisted:python_version != '3.3' and python_version != '3.4'": "twisted>=17.1.0",
Expand Down
129 changes: 129 additions & 0 deletions tests/unit/test_converters.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
# Standard library imports
import typing
import sys

# Third-party imports
import marshmallow

try:
import pydantic
except ImportError:
if sys.version_info >= (3, 6):
raise

import pytest

# Local imports
Expand Down Expand Up @@ -448,3 +456,124 @@ def test_dict_converter(self):
# Verify with non-map: use value converter
output = converter(1)
assert output == "1"


@pytest.mark.skipif(
sys.version_info < (3, 6), reason="requires python3.6 or higher"
)
class TestPydanticConverter(object):
@pytest.fixture
def pydantic_model_mock(self, mocker):
class Model(pydantic.BaseModel):
def __new__(cls, *args, **kwargs):
return model

model = mocker.Mock(spec=Model)
return model, Model

def test_init_without_pydantic(self, mocker):
mocker.patch.object(
converters.PydanticConverter,
"pydantic",
new_callable=mocker.PropertyMock,
return_value=None,
)

with pytest.raises(ImportError):
converters.PydanticConverter()

def test_create_request_body_converter(self, pydantic_model_mock):
expected_result = {"id": 0}
request_body = {}

model_mock, model = pydantic_model_mock
model_mock.dict.return_value = expected_result

converter = converters.PydanticConverter()
request_converter = converter.create_request_body_converter(model)

result = request_converter.convert(request_body)

assert result == expected_result
model_mock.dict.assert_called_once()

def test_create_request_body_converter_without_schema(self, mocker):
expected_result = None
converter = converters.PydanticConverter()

result = converter.create_request_body_converter(mocker.sentinel)

assert result is expected_result

def test_create_response_body_converter(self, mocker, pydantic_model_mock):
expected_result = "data"
model_mock, model = pydantic_model_mock

parse_obj_mock = mocker.patch.object(
model, "parse_obj", return_value=expected_result
)

response = mocker.Mock(spec=["json"])
response.json.return_value = {}

converter = converters.PydanticConverter()
c = converter.create_response_body_converter(model)

result = c.convert(response)

response.json.assert_called_once()
parse_obj_mock.assert_called_once_with(response.json())
assert result == expected_result

def test_create_response_body_converter_invalid_response(
self, mocker, pydantic_model_mock
):
data = {"quick": "fox"}
_, model = pydantic_model_mock

parse_obj_mock = mocker.patch.object(
model, "parse_obj", side_effect=pydantic.ValidationError([], model)
)

converter = converters.PydanticConverter()
c = converter.create_response_body_converter(model)

with pytest.raises(pydantic.ValidationError):
c.convert(data)

parse_obj_mock.assert_called_once_with(data)

def test_create_response_body_converter_without_schema(self):
expected_result = None
converter = converters.PydanticConverter()

result = converter.create_response_body_converter("not a schema")

assert result is expected_result

def test_create_string_converter(self, pydantic_model_mock):
expected_result = None
_, model = pydantic_model_mock
converter = converters.PydanticConverter()

c = converter.create_string_converter(model, None)

assert c is expected_result

@pytest.mark.parametrize(
"pydantic_installed,expected",
[
pytest.param(
True, [converters.PydanticConverter], id="pydantic_installed"
),
pytest.param(None, [], id="pydantic_not_installed"),
],
)
def test_register(self, pydantic_installed, expected):
converter = converters.PydanticConverter
converter.pydantic = pydantic_installed

register_ = []
converter.register_if_necessary(register_.append)

assert register_ == expected
2 changes: 2 additions & 0 deletions uplink/converters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
# fmt: off
from uplink.converters.standard import StandardConverter
from uplink.converters.marshmallow_ import MarshmallowConverter
from uplink.converters.pydantic_ import PydanticConverter
from uplink.converters.typing_ import TypingConverter
# fmt: on

__all__ = [
"StandardConverter",
"MarshmallowConverter",
"PydanticConverter",
"TypingConverter",
"get_default_converter_factories",
"register_default_converter_factory",
Expand Down
Loading

0 comments on commit 2f4260d

Please sign in to comment.