From 8b0c9c0aa672e57a4842adfbc12df8f78b367633 Mon Sep 17 00:00:00 2001 From: "P. Raj Kumar" Date: Sun, 9 Feb 2020 11:47:02 -0800 Subject: [PATCH 01/11] Update CHANGELOG --- CHANGELOG.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b13e5eae..5173c0bf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog`_, and this project adheres to the `Semantic Versioning`_ scheme. +0.10.0_ - Unreleased +==================== + 0.9.1_ - 2020-02-08 =================== Fixed @@ -308,7 +311,8 @@ Added .. _`Semantic Versioning`: https://packaging.python.org/tutorials/distributing-packages/#semantic-versioning-preferred .. Releases -.. _0.9.1: https://github.com/prkumar/uplink/compare/v0.9.1...HEAD +.. _0.10.0: https://github.com/prkumar/uplink/compare/v0.9.1...HEAD +.. _0.9.1: https://github.com/prkumar/uplink/compare/v0.9.0...v0.9.1 .. _0.9.0: https://github.com/prkumar/uplink/compare/v0.8.0...v0.9.0 .. _0.8.0: https://github.com/prkumar/uplink/compare/v0.7.0...v0.8.0 .. _0.7.0: https://github.com/prkumar/uplink/compare/v0.6.1...v0.7.0 From 476c096ad9a27a9b1581b662eb7000d64be7bed0 Mon Sep 17 00:00:00 2001 From: "P. Raj Kumar" Date: Sun, 9 Feb 2020 12:25:47 -0800 Subject: [PATCH 02/11] Update fallback strategies for converters --- tests/unit/test_converters.py | 14 -------------- uplink/arguments.py | 3 ++- uplink/converters/standard.py | 26 ++++++-------------------- 3 files changed, 8 insertions(+), 35 deletions(-) diff --git a/tests/unit/test_converters.py b/tests/unit/test_converters.py index 1c859824..545cf951 100644 --- a/tests/unit/test_converters.py +++ b/tests/unit/test_converters.py @@ -29,17 +29,6 @@ def test_convert_with_caster(self, mocker): assert return_value == 3 -class TestRequestBodyConverter(object): - def test_convert_str(self): - converter_ = standard.RequestBodyConverter() - assert converter_.convert("example") == "example" - - def test_convert_obj(self): - converter_ = standard.RequestBodyConverter() - example = {"hello": "2"} - assert converter_.convert(example) == example - - class TestStringConverter(object): def test_convert(self): converter_ = standard.StringConverter() @@ -55,9 +44,6 @@ def test_create_response_body_converter(self, converter_mock): converter = factory.create_response_body_converter(converter_mock) assert converter is converter_mock - # Run & Verify: Otherwise, return None - assert None is factory.create_response_body_converter("converter") - class TestConverterFactoryRegistry(object): backend = converters.ConverterFactoryRegistry._converter_factory_registry diff --git a/uplink/arguments.py b/uplink/arguments.py index 76dc146f..3c45960d 100644 --- a/uplink/arguments.py +++ b/uplink/arguments.py @@ -179,7 +179,8 @@ def converter_key(self): # pragma: no cover def modify_request(self, request_builder, value): argument_type, converter_key = self.type, self.converter_key converter = request_builder.get_converter(converter_key, argument_type) - self._modify_request(request_builder, converter(value)) + converted_value = converter(value) if converter else value + self._modify_request(request_builder, converted_value) class TypedArgument(ArgumentAnnotation): diff --git a/uplink/converters/standard.py b/uplink/converters/standard.py index 6edabce0..d912e492 100644 --- a/uplink/converters/standard.py +++ b/uplink/converters/standard.py @@ -1,6 +1,3 @@ -# Standard library imports -import json - # Local imports from uplink.converters import interfaces, register_default_converter_factory @@ -19,18 +16,6 @@ def convert(self, value): return self._converter(value) -class RequestBodyConverter(interfaces.Converter): - @staticmethod - def _default_json_dumper(obj): - return obj.__dict__ # pragma: no cover - - def convert(self, value): - if isinstance(value, str): - return value - dumped = json.dumps(value, default=self._default_json_dumper) - return json.loads(dumped) - - class StringConverter(interfaces.Converter): def convert(self, value): return str(value) @@ -44,12 +29,13 @@ class StandardConverter(interfaces.Factory): converters could handle a particular type. """ - def create_response_body_converter(self, type_, *args, **kwargs): - if isinstance(type_, interfaces.Converter): - return type_ + @staticmethod + def pass_through_converter(type_, *args, **kwargs): + return type_ - def create_request_body_converter(self, type_, *args, **kwargs): - return Cast(type_, RequestBodyConverter()) # pragma: no cover + create_response_body_converter = ( + create_request_body_converter + ) = pass_through_converter def create_string_converter(self, type_, *args, **kwargs): return Cast(type_, StringConverter()) # pragma: no cover From 3e8d2a63ca6a2f15eb8b3a281276096f4570b142 Mon Sep 17 00:00:00 2001 From: "P. Raj Kumar" Date: Sun, 9 Feb 2020 14:54:20 -0800 Subject: [PATCH 03/11] Revert "Update fallback strategies for converters" This reverts commit 476c096ad9a27a9b1581b662eb7000d64be7bed0. --- tests/unit/test_converters.py | 14 ++++++++++++++ uplink/arguments.py | 3 +-- uplink/converters/standard.py | 26 ++++++++++++++++++++------ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_converters.py b/tests/unit/test_converters.py index 545cf951..1c859824 100644 --- a/tests/unit/test_converters.py +++ b/tests/unit/test_converters.py @@ -29,6 +29,17 @@ def test_convert_with_caster(self, mocker): assert return_value == 3 +class TestRequestBodyConverter(object): + def test_convert_str(self): + converter_ = standard.RequestBodyConverter() + assert converter_.convert("example") == "example" + + def test_convert_obj(self): + converter_ = standard.RequestBodyConverter() + example = {"hello": "2"} + assert converter_.convert(example) == example + + class TestStringConverter(object): def test_convert(self): converter_ = standard.StringConverter() @@ -44,6 +55,9 @@ def test_create_response_body_converter(self, converter_mock): converter = factory.create_response_body_converter(converter_mock) assert converter is converter_mock + # Run & Verify: Otherwise, return None + assert None is factory.create_response_body_converter("converter") + class TestConverterFactoryRegistry(object): backend = converters.ConverterFactoryRegistry._converter_factory_registry diff --git a/uplink/arguments.py b/uplink/arguments.py index 3c45960d..76dc146f 100644 --- a/uplink/arguments.py +++ b/uplink/arguments.py @@ -179,8 +179,7 @@ def converter_key(self): # pragma: no cover def modify_request(self, request_builder, value): argument_type, converter_key = self.type, self.converter_key converter = request_builder.get_converter(converter_key, argument_type) - converted_value = converter(value) if converter else value - self._modify_request(request_builder, converted_value) + self._modify_request(request_builder, converter(value)) class TypedArgument(ArgumentAnnotation): diff --git a/uplink/converters/standard.py b/uplink/converters/standard.py index d912e492..6edabce0 100644 --- a/uplink/converters/standard.py +++ b/uplink/converters/standard.py @@ -1,3 +1,6 @@ +# Standard library imports +import json + # Local imports from uplink.converters import interfaces, register_default_converter_factory @@ -16,6 +19,18 @@ def convert(self, value): return self._converter(value) +class RequestBodyConverter(interfaces.Converter): + @staticmethod + def _default_json_dumper(obj): + return obj.__dict__ # pragma: no cover + + def convert(self, value): + if isinstance(value, str): + return value + dumped = json.dumps(value, default=self._default_json_dumper) + return json.loads(dumped) + + class StringConverter(interfaces.Converter): def convert(self, value): return str(value) @@ -29,13 +44,12 @@ class StandardConverter(interfaces.Factory): converters could handle a particular type. """ - @staticmethod - def pass_through_converter(type_, *args, **kwargs): - return type_ + def create_response_body_converter(self, type_, *args, **kwargs): + if isinstance(type_, interfaces.Converter): + return type_ - create_response_body_converter = ( - create_request_body_converter - ) = pass_through_converter + def create_request_body_converter(self, type_, *args, **kwargs): + return Cast(type_, RequestBodyConverter()) # pragma: no cover def create_string_converter(self, type_, *args, **kwargs): return Cast(type_, StringConverter()) # pragma: no cover From 7b8f2031d372e8753d2659522b95604fd44b8fea Mon Sep 17 00:00:00 2001 From: "P. Raj Kumar" Date: Tue, 26 May 2020 16:23:12 +1200 Subject: [PATCH 04/11] Fix FileNotFoundError in Python 3.6 builds --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index ffbb2819..a9fdca81 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ before_script: - if [[ $TRAVIS_PYTHON_VERSION == 3.6 ]]; then pip install flake8 flake8-bugbear; fi script: - tox -e py - - if [[ $TRAVIS_PYTHON_VERSION == 3.6 ]]; then flake8 uplink tests setup.py docs/conf.py; fi + - if [[ $TRAVIS_PYTHON_VERSION == 3.6 ]]; then flake8 uplink tests setup.py docs/source/conf.py; fi after_success: - pip install codecov - codecov From c781190c6f7cf7070481b49e406636fee820eb92 Mon Sep 17 00:00:00 2001 From: "P. Raj Kumar" Date: Tue, 26 May 2020 16:30:45 +1200 Subject: [PATCH 05/11] Fix flake8 violations in conf.py --- docs/source/conf.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 4e59d301..9a860637 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -133,9 +133,8 @@ # Custom sidebar templates, maps document names to template names. html_sidebars = { - 'index': ['about.html', 'links.html', 'navigation.html', 'searchbox.html'], - '**': ["about.html", 'localtoc.html', 'relations.html', - 'searchbox.html'], + 'index': ['about.html', 'links.html', 'navigation.html', 'searchbox.html'], + '**': ["about.html", 'localtoc.html', 'relations.html', 'searchbox.html'], 'changes': ['about.html', 'searchbox.html'] } From 5288f5b0d128ed7c74d6bad930c6cd36af2716b7 Mon Sep 17 00:00:00 2001 From: "P. Raj Kumar" Date: Sat, 25 Jul 2020 18:58:10 -0700 Subject: [PATCH 06/11] Remove Python 2.7 & 3.4 from Travis config (#199) Both versions have reached their EOL. --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index a9fdca81..300a997c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,5 @@ language: python python: - - '2.7' - - '3.4' - '3.5' - '3.6' - '3.7' From 4c9a380ad8ee49d8ba1e8a8921cd7a0db4f0e5f9 Mon Sep 17 00:00:00 2001 From: "P. Raj Kumar" Date: Sun, 26 Jul 2020 13:43:47 -0700 Subject: [PATCH 07/11] Make consumer method look like the original func (#198) This involves copying function attributes (e.g., docstrings) using `functools.update_wrapper`. Original post on Gitter: https://gitter.im/python-uplink/Lobby?at=5efcf94ce0e5673398e865e2 --- tests/integration/test_basic.py | 17 ++++++++++++++++- tests/unit/test_commands.py | 5 ++++- uplink/builder.py | 25 +++++++++++++++++++++---- uplink/commands.py | 25 +++++++++++++++++++++---- uplink/interfaces.py | 3 +++ 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/tests/integration/test_basic.py b/tests/integration/test_basic.py index bc5804c5..0f28dc1e 100644 --- a/tests/integration/test_basic.py +++ b/tests/integration/test_basic.py @@ -14,7 +14,7 @@ class GitHubService(uplink.Consumer): @uplink.timeout(15) @uplink.get("/users/{user}/repos") def list_repos(self, user): - pass + """List all public repositories for a specific user.""" @uplink.returns.json @uplink.get("/users/{user}/repos/{repo}") @@ -26,6 +26,21 @@ def forward(self, url): pass +def test_list_repo_wrapper(mock_client): + """Ensures that the consumer method looks like the original func.""" + github = GitHubService(base_url=BASE_URL, client=mock_client) + assert ( + github.list_repos.__doc__ + == GitHubService.list_repos.__doc__ + == "List all public repositories for a specific user." + ) + assert ( + github.list_repos.__name__ + == GitHubService.list_repos.__name__ + == "list_repos" + ) + + def test_list_repo(mock_client): github = GitHubService(base_url=BASE_URL, client=mock_client) github.list_repos("prkumar") diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 1fbcfa99..ad678055 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -39,7 +39,6 @@ def func(): http_method = commands.HttpMethod("METHOD", uri="/{hello}") builder = http_method(func) assert isinstance(builder, commands.RequestDefinitionBuilder) - assert builder.__name__ == func.__name__ assert builder.method == "METHOD" assert list(builder.uri.remaining_variables) == ["hello"] @@ -131,6 +130,7 @@ def test_method_handler_builder_getter( self, annotation_handler_builder_mock ): builder = commands.RequestDefinitionBuilder( + None, None, None, type(annotation_handler_builder_mock)(), @@ -143,6 +143,7 @@ def test_build(self, mocker, annotation_handler_builder_mock): method_handler_builder = annotation_handler_builder_mock uri_definition_builder = mocker.Mock(spec=commands.URIDefinitionBuilder) builder = commands.RequestDefinitionBuilder( + None, "method", uri_definition_builder, argument_handler_builder, @@ -164,6 +165,7 @@ def test_auto_fill_when_not_done( method_handler_builder = annotation_handler_builder_mock uri_definition_builder = mocker.Mock(spec=commands.URIDefinitionBuilder) builder = commands.RequestDefinitionBuilder( + None, "method", uri_definition_builder, argument_handler_builder, @@ -189,6 +191,7 @@ def test_auto_fill_when_not_done_fails( method_handler_builder = annotation_handler_builder_mock uri_definition_builder = mocker.Mock(spec=commands.URIDefinitionBuilder) builder = commands.RequestDefinitionBuilder( + None, "method", uri_definition_builder, argument_handler_builder, diff --git a/uplink/builder.py b/uplink/builder.py index d2f48027..dc119967 100644 --- a/uplink/builder.py +++ b/uplink/builder.py @@ -193,18 +193,35 @@ def _build_definition(self): ) def __get__(self, instance, owner): + # TODO: + # Consider caching by instance/owner using WeakKeyDictionary. + # This will avoid the extra copy/create per attribute reference. + # However, we should do this after investigating for any latent cases + # of unnecessary overhead in the codebase as a whole. if instance is None: - return self._request_definition_builder.copy() + # This code path is traditionally called when applying a class + # decorator to a Consumer. We should return a copy of the definition + # builder to avoid class decorators on a subclass from polluting + # other siblings (#152). + value = self._request_definition_builder.copy() else: - return instance.session.create(instance, self._request_definition) + value = instance.session.create(instance, self._request_definition) + + # Make the return value look like the original method (e.g., inherit + # docstrings and other function attributes). + # TODO: Ideally, we should wrap once instead of on each reference. + self._request_definition_builder.update_wrapper(value) + return value class ConsumerMeta(type): @staticmethod def _wrap_if_definition(cls_name, key, value): + wrapped_value = value if isinstance(value, interfaces.RequestDefinitionBuilder): - value = ConsumerMethod(cls_name, key, value) - return value + wrapped_value = ConsumerMethod(cls_name, key, value) + value.update_wrapper(wrapped_value) + return wrapped_value @staticmethod def _set_init_handler(namespace): diff --git a/uplink/commands.py b/uplink/commands.py index c97820f5..8eeef44a 100644 --- a/uplink/commands.py +++ b/uplink/commands.py @@ -80,8 +80,14 @@ def build(self): class RequestDefinitionBuilder(interfaces.RequestDefinitionBuilder): def __init__( - self, method, uri, argument_handler_builder, method_handler_builder + self, + func, + method, + uri, + argument_handler_builder, + method_handler_builder, ): + self._func = func self._method = method self._uri = uri self._argument_handler_builder = argument_handler_builder @@ -193,15 +199,23 @@ def extend(self, uri=None, args=()): uri = self.uri.template if uri is None else uri return factory(uri, args) - def _extend(self, method, uri, arg_handler, _): + def _extend(self, func, method, uri, arg_handler, _): builder = RequestDefinitionBuilder( - method, uri, arg_handler, self.method_handler_builder.copy() + # Extended consumer methods should only inherit the decorators and + # not any function annotations, since the new method can have a + # different signature than the original. + func, + method, + uri, + arg_handler, + self.method_handler_builder.copy(), ) builder.return_type = self.return_type return builder def copy(self): builder = RequestDefinitionBuilder( + self._func, self._method, self._uri, self._argument_handler_builder.copy(), @@ -224,6 +238,9 @@ def _auto_fill_remaining_arguments(self): path_vars = dict.fromkeys(matching, arguments.Path) self.argument_handler_builder.set_annotations(path_vars) + def update_wrapper(self, wrapper): + functools.update_wrapper(wrapper, self._func) + def build(self): if not self._argument_handler_builder.is_done(): self._auto_fill_remaining_arguments() @@ -316,6 +333,7 @@ def __call__( func, spec.args ) builder = request_definition_builder_factory( + func, self._method, URIDefinitionBuilder(self._uri), arg_handler, @@ -329,7 +347,6 @@ def __call__( # Use return value type hint as expected return type if spec.return_annotation is not None: builder = returns.schema(spec.return_annotation)(builder) - functools.update_wrapper(builder, func) builder = self._add_args(builder) return builder diff --git a/uplink/interfaces.py b/uplink/interfaces.py index b610aaf7..b02bd862 100644 --- a/uplink/interfaces.py +++ b/uplink/interfaces.py @@ -96,6 +96,9 @@ def argument_handler_builder(self): def method_handler_builder(self): raise NotImplementedError + def update_wrapper(self, wrapper): + raise NotImplementedError + def build(self): raise NotImplementedError From a933f1f3db690f9bb233c0d634a1b9a9bddfa3f9 Mon Sep 17 00:00:00 2001 From: Guilherme Martins Crocetti Date: Wed, 5 Aug 2020 20:19:01 -0300 Subject: [PATCH 08/11] feat(pydantic): Create converters and factory to serialize/deserialize client's behavior into pydantic's models feat(setup): Add pydantic as extra package and update pipenv --- Pipfile | 2 +- setup.py | 1 + tests/unit/test_converters.py | 129 +++++++++++++++++++++++++++++++++ uplink/converters/__init__.py | 2 + uplink/converters/pydantic_.py | 85 ++++++++++++++++++++++ 5 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 uplink/converters/pydantic_.py diff --git a/Pipfile b/Pipfile index e94a592c..66a32637 100644 --- a/Pipfile +++ b/Pipfile @@ -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 = "."} diff --git a/setup.py b/setup.py index 404ababc..911a033c 100644 --- a/setup.py +++ b/setup.py @@ -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", diff --git a/tests/unit/test_converters.py b/tests/unit/test_converters.py index 1c859824..29836319 100644 --- a/tests/unit/test_converters.py +++ b/tests/unit/test_converters.py @@ -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 @@ -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 diff --git a/uplink/converters/__init__.py b/uplink/converters/__init__.py index b9cd91fe..17532d59 100644 --- a/uplink/converters/__init__.py +++ b/uplink/converters/__init__.py @@ -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", diff --git a/uplink/converters/pydantic_.py b/uplink/converters/pydantic_.py new file mode 100644 index 00000000..5b5e2de6 --- /dev/null +++ b/uplink/converters/pydantic_.py @@ -0,0 +1,85 @@ +from uplink.converters import register_default_converter_factory +from uplink.converters.interfaces import Factory, Converter +from uplink.utils import is_subclass + + +class _PydanticRequestBody(Converter): + def __init__(self, model): + self._model = model + + def convert(self, value): + return self._model(**value).dict() + + +class _PydanticResponseBody(Converter): + def __init__(self, model): + self._model = model + + def convert(self, response): + try: + data = response.json() + except AttributeError: + data = response + + return self._model.parse_obj(data) + + +class PydanticConverter(Factory): + """ + A converter that serializes and deserializes values using + :py:mod:`pydantic` models. + + To deserialize JSON responses into Python objects with this + converter, define a :py:class:`pydantic.BaseModel` subclass and set + it as the return annotation of a consumer method: + + .. code-block:: python + + @get("/users") + def get_users(self, username) -> UserModel(): + '''Fetch a single user''' + + Note: + + This converter is an optional feature and requires the + :py:mod:`pydantic` package. For example, here's how to + install this feature using pip:: + + $ pip install uplink[pydantic] + """ + + try: + import pydantic + except ImportError: # pragma: no cover + pydantic = None + + def __init__(self): + if self.pydantic is None: + raise ImportError("No module named 'pydantic'") + + def _get_model(self, type_): + if is_subclass(type_, self.pydantic.BaseModel): + return type_ + raise ValueError("Expected pydantic.BaseModel subclass or instance") + + def _make_converter(self, converter, type_): + try: + model = self._get_model(type_) + except ValueError: + return None + + return converter(model) + + def create_request_body_converter(self, type_, *args, **kwargs): + return self._make_converter(_PydanticRequestBody, type_) + + def create_response_body_converter(self, type_, *args, **kwargs): + return self._make_converter(_PydanticResponseBody, type_) + + @classmethod + def register_if_necessary(cls, register_func): + if cls.pydantic is not None: + register_func(cls) + + +PydanticConverter.register_if_necessary(register_default_converter_factory) From 07edb49063d2573f0f75c3bd891b75054856fbf6 Mon Sep 17 00:00:00 2001 From: Guilherme Martins Crocetti Date: Thu, 27 Aug 2020 20:31:39 -0300 Subject: [PATCH 09/11] docs(pydantic): Add docstrings for pydantic's converter and examples on how to use it --- AUTHORS.rst | 1 + README.rst | 7 ++- docs/source/dev/converters.rst | 15 +++++ docs/source/index.rst | 1 + docs/source/user/install.rst | 5 +- docs/source/user/serialization.rst | 88 +++++++++++++++++++++++++++++- uplink/converters/pydantic_.py | 13 ++++- 7 files changed, 123 insertions(+), 7 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index c1c43a4a..de74a9f5 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -15,3 +15,4 @@ Contributors - Alexander Duryagin (`@daa `_) - Sakorn Waungwiwatsin (`@SakornW `_) - Jacob Floyd (`@cognifloyd `_) +- Guilherme Crocetti (`@gmcrocetti `_) diff --git a/README.rst b/README.rst index c685d526..b79dd23b 100644 --- a/README.rst +++ b/README.rst @@ -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 ` (e.g., list of Repos) - **Extendable** @@ -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 `_. When installing Uplink with ``pip``, you can select extras using the format: @@ -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 diff --git a/docs/source/dev/converters.rst b/docs/source/dev/converters.rst index fc426678..74c18eef 100644 --- a/docs/source/dev/converters.rst +++ b/docs/source/dev/converters.rst @@ -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 diff --git a/docs/source/index.rst b/docs/source/index.rst index 382927fd..1ea83726 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -73,6 +73,7 @@ Features - Define :ref:`custom converters ` for your own objects - Support for |marshmallow|_ schemas and :ref:`handling collections ` (e.g., list of Users) + - Support for pydantic models and :ref:`handling collections ` (e.g., list of Repos) - **Extendable** diff --git a/docs/source/user/install.rst b/docs/source/user/install.rst index 04379c41..29ad311d 100644 --- a/docs/source/user/install.rst +++ b/docs/source/user/install.rst @@ -53,6 +53,9 @@ Extra Description for `converting JSON responses directly into Python objects `_ 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 `_ and receiving :py:class:`~twisted.internet.defer.Deferred` responses. @@ -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] diff --git a/docs/source/user/serialization.rst b/docs/source/user/serialization.rst index ce651b13..41a441f4 100644 --- a/docs/source/user/serialization.rst +++ b/docs/source/user/serialization.rst @@ -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: @@ -79,6 +79,71 @@ schema: For a more complete example of Uplink's :mod:`marshmallow` support, check out `this example on GitHub `_. +.. _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 ` 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 ============================ @@ -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 diff --git a/uplink/converters/pydantic_.py b/uplink/converters/pydantic_.py index 5b5e2de6..00e36f01 100644 --- a/uplink/converters/pydantic_.py +++ b/uplink/converters/pydantic_.py @@ -1,3 +1,8 @@ +""" +This module defines a converter that uses :py:mod:`pydantic` models +to deserialize and serialize values. +""" + from uplink.converters import register_default_converter_factory from uplink.converters.interfaces import Factory, Converter from uplink.utils import is_subclass @@ -35,9 +40,10 @@ class PydanticConverter(Factory): .. code-block:: python + @returns.json() @get("/users") - def get_users(self, username) -> UserModel(): - '''Fetch a single user''' + def get_users(self, username) -> List[UserModel]: + '''Fetch multiple users''' Note: @@ -54,6 +60,9 @@ def get_users(self, username) -> UserModel(): pydantic = None def __init__(self): + """ + Validates if :py:mod:`pydantic` is installed + """ if self.pydantic is None: raise ImportError("No module named 'pydantic'") From 89c43d1b9d65965ab9411b80b343acba7484c93c Mon Sep 17 00:00:00 2001 From: "P. Raj Kumar" Date: Sun, 9 Feb 2020 12:25:47 -0800 Subject: [PATCH 10/11] Update fallback strategies for converters --- tests/unit/test_converters.py | 14 -------------- uplink/arguments.py | 3 ++- uplink/converters/standard.py | 26 ++++++-------------------- 3 files changed, 8 insertions(+), 35 deletions(-) diff --git a/tests/unit/test_converters.py b/tests/unit/test_converters.py index 29836319..d65acccf 100644 --- a/tests/unit/test_converters.py +++ b/tests/unit/test_converters.py @@ -37,17 +37,6 @@ def test_convert_with_caster(self, mocker): assert return_value == 3 -class TestRequestBodyConverter(object): - def test_convert_str(self): - converter_ = standard.RequestBodyConverter() - assert converter_.convert("example") == "example" - - def test_convert_obj(self): - converter_ = standard.RequestBodyConverter() - example = {"hello": "2"} - assert converter_.convert(example) == example - - class TestStringConverter(object): def test_convert(self): converter_ = standard.StringConverter() @@ -63,9 +52,6 @@ def test_create_response_body_converter(self, converter_mock): converter = factory.create_response_body_converter(converter_mock) assert converter is converter_mock - # Run & Verify: Otherwise, return None - assert None is factory.create_response_body_converter("converter") - class TestConverterFactoryRegistry(object): backend = converters.ConverterFactoryRegistry._converter_factory_registry diff --git a/uplink/arguments.py b/uplink/arguments.py index 76dc146f..3c45960d 100644 --- a/uplink/arguments.py +++ b/uplink/arguments.py @@ -179,7 +179,8 @@ def converter_key(self): # pragma: no cover def modify_request(self, request_builder, value): argument_type, converter_key = self.type, self.converter_key converter = request_builder.get_converter(converter_key, argument_type) - self._modify_request(request_builder, converter(value)) + converted_value = converter(value) if converter else value + self._modify_request(request_builder, converted_value) class TypedArgument(ArgumentAnnotation): diff --git a/uplink/converters/standard.py b/uplink/converters/standard.py index 6edabce0..d912e492 100644 --- a/uplink/converters/standard.py +++ b/uplink/converters/standard.py @@ -1,6 +1,3 @@ -# Standard library imports -import json - # Local imports from uplink.converters import interfaces, register_default_converter_factory @@ -19,18 +16,6 @@ def convert(self, value): return self._converter(value) -class RequestBodyConverter(interfaces.Converter): - @staticmethod - def _default_json_dumper(obj): - return obj.__dict__ # pragma: no cover - - def convert(self, value): - if isinstance(value, str): - return value - dumped = json.dumps(value, default=self._default_json_dumper) - return json.loads(dumped) - - class StringConverter(interfaces.Converter): def convert(self, value): return str(value) @@ -44,12 +29,13 @@ class StandardConverter(interfaces.Factory): converters could handle a particular type. """ - def create_response_body_converter(self, type_, *args, **kwargs): - if isinstance(type_, interfaces.Converter): - return type_ + @staticmethod + def pass_through_converter(type_, *args, **kwargs): + return type_ - def create_request_body_converter(self, type_, *args, **kwargs): - return Cast(type_, RequestBodyConverter()) # pragma: no cover + create_response_body_converter = ( + create_request_body_converter + ) = pass_through_converter def create_string_converter(self, type_, *args, **kwargs): return Cast(type_, StringConverter()) # pragma: no cover From d616b714fb7fb31f8b3bb3d8225e94f92b7e32c1 Mon Sep 17 00:00:00 2001 From: "P. Raj Kumar" Date: Sat, 17 Oct 2020 15:15:48 -0700 Subject: [PATCH 11/11] Update CHANGELOG for v0.9.2 --- CHANGELOG.rst | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5173c0bf..7d2c9ed2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,8 +6,19 @@ All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog`_, and this project adheres to the `Semantic Versioning`_ scheme. -0.10.0_ - Unreleased +0.9.2_ - 2020-10-18 ==================== +Added +----- +- Support for (de)serializing subclasses of `pydantic`_'s `BaseModel` + (`#200`_ by `@gmcrocetti`_) + +Fixed +----- +- Using the ``@get``, ``@post``, ``@patch``, etc. decorators should retain the + docstring of the wrapped method (`#198`_) +- The ``Body`` and ``Part`` argument annotations should support uploading binary + data (`#180`_, `#183`_, `#204`_) 0.9.1_ - 2020-02-08 =================== @@ -309,9 +320,10 @@ Added .. _Retrofit: http://square.github.io/retrofit/ .. _`Keep a Changelog`: http://keepachangelog.com/en/1.0.0/ .. _`Semantic Versioning`: https://packaging.python.org/tutorials/distributing-packages/#semantic-versioning-preferred +.. _pydantic: https://pydantic-docs.helpmanual.io/ .. Releases -.. _0.10.0: https://github.com/prkumar/uplink/compare/v0.9.1...HEAD +.. _0.9.2: https://github.com/prkumar/uplink/compare/v0.9.1...v0.9.2 .. _0.9.1: https://github.com/prkumar/uplink/compare/v0.9.0...v0.9.1 .. _0.9.0: https://github.com/prkumar/uplink/compare/v0.8.0...v0.9.0 .. _0.8.0: https://github.com/prkumar/uplink/compare/v0.7.0...v0.8.0 @@ -349,7 +361,12 @@ Added .. _#165: https://github.com/prkumar/uplink/pull/165 .. _#167: https://github.com/prkumar/uplink/issues/167 .. _#169: https://github.com/prkumar/uplink/pull/169 +.. _#180: https://github.com/prkumar/uplink/pull/180 +.. _#183: https://github.com/prkumar/uplink/pull/183 .. _#188: https://github.com/prkumar/uplink/pull/188 +.. _#198: https://github.com/prkumar/uplink/pull/198 +.. _#200: https://github.com/prkumar/uplink/pull/200 +.. _#204: https://github.com/prkumar/uplink/pull/204 .. Contributors .. _@daa: https://github.com/daa @@ -358,3 +375,4 @@ Added .. _@itstehkman: https://github.com/itstehkman .. _@kadrach: https://github.com/kadrach .. _@cognifloyd: https://github.com/cognifloyd +.. _@gmcrocetti: https://github.com/gmcrocetti