From d5068a433c28c401c6a3631dc1c0e80eacf29d0e Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sat, 11 Dec 2021 12:31:21 +0100 Subject: [PATCH 01/48] Deprecated Python 2 support --- .github/workflows/workflow.yml | 22 +------------ CHANGELOG.rst | 5 +++ README.rst | 18 ++++------- decorest/client.py | 10 ++---- decorest/decorators.py | 12 ++------ decorest/types.py | 12 ++------ decorest/utils.py | 40 ++++++++---------------- requirements.txt | 1 - setup.py | 6 ++-- tests/httpbin_test.py | 33 +++++++++----------- tests/petstore_test.py | 18 +++++------ tox-py2.ini | 56 ---------------------------------- tox.ini | 3 -- 13 files changed, 58 insertions(+), 178 deletions(-) delete mode 100644 tox-py2.ini diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 42b6d2d..60eb64b 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -9,26 +9,6 @@ on: paths-ignore: - '**.rst' jobs: - python2: - name: Test on Python 2 - runs-on: ${{ matrix.operating-system }} - env: - TOX_DOCKER_VERSION: 1.7.0 - strategy: - matrix: - operating-system: [ ubuntu-latest ] - python-version: [ '2.7' ] - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Install Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - run: pip install tox tox-gh-actions tox-docker==$TOX_DOCKER_VERSION - - run: tox -c tox-py2.ini -e flake8,basic,swaggerpetstore,httpbin python3: name: Test on Python 3 runs-on: ${{ matrix.operating-system }} @@ -37,7 +17,7 @@ jobs: strategy: matrix: operating-system: [ ubuntu-latest ] - python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10' ] + python-version: [ '3.5', '3.6', '3.7', '3.8', '3.9', '3.10' ] steps: - name: Checkout uses: actions/checkout@v2 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3c23394..14056de 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,5 +1,10 @@ .. :changelog: +0.1.0 (...) +++++++++++++++++++ + +* Deprecated Python 2 support + 0.0.7 (2021-11-27) ++++++++++++++++++ diff --git a/README.rst b/README.rst index 60291f0..5aaf903 100644 --- a/README.rst +++ b/README.rst @@ -50,6 +50,8 @@ For example: Installation ============ +**Note:** *As of version `0.1.0`, decorest_ supports only Python 3.5+.* + Using pip: .. code-block:: bash @@ -81,8 +83,8 @@ Choosing backend ---------------- decorest_ supports currently 2 backends: - * requests_ (Python 2 and 3) - * httpx_ (only Python 3) + * requests_ (default) + * httpx_ To select a specific backend, simply pass it's name to the constructor of the client: @@ -543,13 +545,9 @@ Create virtual env .. code-block:: bash - # For Python 3 - virtualenv -p /usr/bin/python3.8 venv3 - source venv3/bin/activate + virtualenv -p /usr/bin/python3 venv + source venv/bin/activate - # For Python 2 - virtualenv -p /usr/bin/python2.7 venv2 - source venv2/bin/activate Formatting ---------- @@ -566,12 +564,8 @@ tox_ and tox-docker_. .. code-block:: bash - # Python 3 python -m tox -e flake8,basic,httpbin,swaggerpetstore - # Python 2 - python -m tox -c tox-py2.ini -e flake8,basic,httpbin,swaggerpetstore - Checking README syntax ---------------------- diff --git a/decorest/client.py b/decorest/client.py index 2c128e3..fa5340e 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -19,9 +19,7 @@ This module contains also some enums for HTTP protocol. """ import logging as LOG - -import six -from six.moves.urllib.parse import urljoin +import urllib.parse from .decorators import get_decor from .utils import normalize_url @@ -123,9 +121,6 @@ def _set_backend(self, backend): if backend not in ('requests', 'httpx'): raise ValueError('{} backend not supported...'.format(backend)) - if backend == 'httpx' and six.PY2: - raise ValueError('httpx backend is not supported on Python 2') - self.backend = backend def _backend(self): @@ -145,6 +140,7 @@ def build_request(self, path_components=[]): """ LOG.debug("Building request from path tokens: %s", path_components) - req = urljoin(normalize_url(self.endpoint), "/".join(path_components)) + req = urllib.parse.urljoin( + normalize_url(self.endpoint), "/".join(path_components)) return req diff --git a/decorest/decorators.py b/decorest/decorators.py index 513d03b..5bc715d 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -29,9 +29,6 @@ import requests from requests.structures import CaseInsensitiveDict -import six -from six import integer_types, iteritems - from .errors import HTTPErrorWrapper from .types import HttpMethod, HttpStatus from .utils import dict_from_args, merge_dicts, render_path @@ -99,7 +96,7 @@ def on(status, handler): def on_decorator(t): if status is Ellipsis: set_decor(t, 'on', {HttpStatus.ANY: handler}) - elif isinstance(status, integer_types): + elif isinstance(status, numbers.Integral): set_decor(t, 'on', {status: handler}) else: raise TypeError("Status in @on decorator must be integer or '...'") @@ -497,10 +494,7 @@ def __dispatch(self, execution_context, http_method, kwargs, req): if isinstance(http_method, str): method = http_method else: - if six.PY2: - method = http_method[0].lower() - else: - method = http_method.value[0].lower() + method = http_method.value[0].lower() return methodcaller(method, req, **kwargs)(execution_context) @@ -533,7 +527,7 @@ def __merge_args(self, args_dict, func, decor): args_decor = get_decor(func, decor) parameters = {} if args_decor: - for arg, param in iteritems(args_decor): + for arg, param in args_decor.items(): if args_dict.get(arg): parameters[param] = args_dict[arg] return parameters diff --git a/decorest/types.py b/decorest/types.py index f7228a1..033ab0b 100644 --- a/decorest/types.py +++ b/decorest/types.py @@ -15,15 +15,9 @@ # limitations under the License. """Various types related to HTTP and REST.""" -from six import PY3 - -DEnum = object -DIntEnum = object - -if PY3: - import enum - DEnum = enum.Enum - DIntEnum = enum.IntEnum +import enum +DEnum = enum.Enum +DIntEnum = enum.IntEnum class HttpMethod(DEnum): diff --git a/decorest/utils.py b/decorest/utils.py index b55271f..f88bb09 100644 --- a/decorest/utils.py +++ b/decorest/utils.py @@ -19,8 +19,6 @@ import logging as LOG import re -import six - def render_path(path, args): """Render REST path from *args.""" @@ -40,31 +38,19 @@ def dict_from_args(func, *args): """Convert function arguments to a dictionary.""" result = {} - if six.PY2: - args_names = inspect.getargspec(func)[0] - args_default_values = inspect.getargspec(func)[3] - # Add bound arguments to the dictionary - for i in range(len(args)): - result[args_names[i]] = args[i] - - # Add any default arguments if were left unbound in method call - for j in range(len(args), len(args_names)): - result[args_names[j]] = args_default_values[len(args_names) - - (j + len(args) - 1)] - else: - parameters = inspect.signature(func).parameters - idx = 0 - for name, parameter in parameters.items(): - if idx < len(args): - # Add bound arguments to the dictionary - result[name] = args[idx] - idx += 1 - elif parameter.default is not inspect.Parameter.empty: - # Add any default arguments if were left unbound in method call - result[name] = parameter.default - idx += 1 - else: - pass + parameters = inspect.signature(func).parameters + idx = 0 + for name, parameter in parameters.items(): + if idx < len(args): + # Add bound arguments to the dictionary + result[name] = args[idx] + idx += 1 + elif parameter.default is not inspect.Parameter.empty: + # Add any default arguments if were left unbound in method call + result[name] = parameter.default + idx += 1 + else: + pass return result diff --git a/requirements.txt b/requirements.txt index c7d0cce..f229360 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -six requests diff --git a/setup.py b/setup.py index bc6df79..e34c6e2 100644 --- a/setup.py +++ b/setup.py @@ -53,19 +53,19 @@ def read(fname): packages=find_packages(exclude=['tests']), include_package_data=True, classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'Natural Language :: English', 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10' ], - install_requires=['requests', 'requests-toolbelt', 'six'], + install_requires=['requests', 'requests-toolbelt'], tests_require=['pytest', 'tox', 'tox-docker'] ) \ No newline at end of file diff --git a/tests/httpbin_test.py b/tests/httpbin_test.py index c2bf676..d5320f8 100644 --- a/tests/httpbin_test.py +++ b/tests/httpbin_test.py @@ -18,7 +18,6 @@ import pytest import time import os -import six import sys import json @@ -38,27 +37,23 @@ def client(backend): # Give Docker and HTTPBin some time to spin up time.sleep(2) - if six.PY2: - host = os.environ["KENNETHREITZ_HTTPBIN_HOST"] - port = os.environ["KENNETHREITZ_HTTPBIN_80_TCP_PORT"] - else: - host = os.environ["HTTPBIN_HOST"] - port = os.environ["HTTPBIN_80_TCP_PORT"] + + host = os.environ["HTTPBIN_HOST"] + port = os.environ["HTTPBIN_80_TCP_PORT"] + return HttpBinClient("http://{host}:{port}".format(host=host, port=port), backend=backend) def basic_auth_client(backend): # Give Docker and HTTPBin some time to spin up - if six.PY2: - host = os.environ["KENNETHREITZ_HTTPBIN_HOST"] - port = os.environ["KENNETHREITZ_HTTPBIN_80_TCP_PORT"] - else: - host = os.environ["HTTPBIN_HOST"] - port = os.environ["HTTPBIN_80_TCP_PORT"] + host = os.environ["HTTPBIN_HOST"] + port = os.environ["HTTPBIN_80_TCP_PORT"] + client = HttpBinClient("http://{host}:{port}".format(host=host, port=port), backend=backend) client._set_auth(HTTPBasicAuth('user', 'password')) + return client @@ -69,12 +64,12 @@ def basic_auth_client(backend): pytest_basic_auth_params = [ pytest.param(client_requests, basic_auth_client_requests, id='requests') ] -if six.PY3: - client_httpx = client('httpx') - pytest_params.append(pytest.param(client_requests, id='httpx')) - basic_auth_client_httpx = basic_auth_client('httpx') - pytest_basic_auth_params.append( - pytest.param(client_httpx, basic_auth_client_httpx, id='httpx')) + +client_httpx = client('httpx') +pytest_params.append(pytest.param(client_requests, id='httpx')) +basic_auth_client_httpx = basic_auth_client('httpx') +pytest_basic_auth_params.append( + pytest.param(client_httpx, basic_auth_client_httpx, id='httpx')) @pytest.mark.parametrize("client", pytest_params) diff --git a/tests/petstore_test.py b/tests/petstore_test.py index e075d58..112bcc9 100644 --- a/tests/petstore_test.py +++ b/tests/petstore_test.py @@ -15,7 +15,6 @@ # limitations under the License. import os -import six import sys import pytest import time @@ -31,21 +30,18 @@ def client(backend): # Give Docker and Swagger Petstore some time to spin up time.sleep(2) host = "localhost" - if six.PY2: - port = os.environ["SWAGGERAPI_PETSTORE_8080_TCP_PORT"] - else: - port = os.environ['PETSTORE_8080_TCP_PORT'] - return PetstoreClient('http://{host}:{port}/api'.format(host=host, - port=port), - backend=backend) + port = os.environ['PETSTORE_8080_TCP_PORT'] + + return PetstoreClient( + 'http://{host}:{port}/api'.format(host=host, port=port), + backend=backend) # Prepare pytest params client_requests = client('requests') pytest_params = [pytest.param(client_requests, id='requests')] -if six.PY3: - client_httpx = client('httpx') - pytest_params.append(pytest.param(client_httpx, id='httpx')) +client_httpx = client('httpx') +pytest_params.append(pytest.param(client_httpx, id='httpx')) @pytest.mark.parametrize("client", pytest_params) diff --git a/tox-py2.ini b/tox-py2.ini deleted file mode 100644 index a72ab6d..0000000 --- a/tox-py2.ini +++ /dev/null @@ -1,56 +0,0 @@ -[tox] -envlist = basic,swaggerpetstore,httpbin,flake8 - - -[testenv] -deps = - pytest - pytest-cov - six - requests - -[testenv:flake8] -basepython = python3 -skip_install = true -deps = - flake8 - flake8-docstrings>=0.2.7 - flake8-import-order>=0.9 - pep8-naming - flake8-colors -commands = - flake8 decorest examples setup.py - - -[testenv:basic] -commands = py.test -v --cov=decorest [] tests/decorators_tests.py - - -[testenv:swaggerpetstore] -docker = - swaggerapi/petstore:1.0.0 -deps = - pytest - pytest-cov - six - requests - requests-toolbelt -commands = py.test -v --cov=decorest [] tests/petstore_test.py - - -[testenv:httpbin] -docker = - kennethreitz/httpbin -deps = - pytest - pytest-cov - six - requests - requests-toolbelt - Pillow - brotlipy -commands = py.test -v --cov=decorest [] tests/httpbin_test.py - -[docker:swaggerapi/petstore:1.0.0] -ports = - 8080:8080/tcp diff --git a/tox.ini b/tox.ini index 1843f72..435f384 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,6 @@ envlist = basic,swaggerpetstore,httpbin,flake8 deps = pytest pytest-cov - six requests httpx @@ -30,7 +29,6 @@ docker = deps = pytest pytest-cov - six requests requests-toolbelt httpx @@ -43,7 +41,6 @@ docker = deps = pytest pytest-cov - six requests requests-toolbelt httpx From 7460d5f3fb4a0cde7573fadbeea4558765700cfb Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sat, 11 Dec 2021 12:38:18 +0100 Subject: [PATCH 02/48] Deprecated Python 3.5 support --- .github/workflows/workflow.yml | 2 +- README.rst | 2 +- setup.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 60eb64b..ea12fdd 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: operating-system: [ ubuntu-latest ] - python-version: [ '3.5', '3.6', '3.7', '3.8', '3.9', '3.10' ] + python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10' ] steps: - name: Checkout uses: actions/checkout@v2 diff --git a/README.rst b/README.rst index 5aaf903..9628bbe 100644 --- a/README.rst +++ b/README.rst @@ -50,7 +50,7 @@ For example: Installation ============ -**Note:** *As of version `0.1.0`, decorest_ supports only Python 3.5+.* +**Note:** *As of version `0.1.0`, decorest_ supports only Python 3.6+.* Using pip: diff --git a/setup.py b/setup.py index e34c6e2..37038a7 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,6 @@ def read(fname): 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', From a0b7a6c19c1bd9fbd72c051271212545a5b7c6a4 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 12 Dec 2021 13:36:22 +0100 Subject: [PATCH 03/48] Added typing annotations to decorest module --- decorest/DELETE.py | 10 +++-- decorest/GET.py | 10 +++-- decorest/HEAD.py | 10 +++-- decorest/OPTIONS.py | 10 +++-- decorest/PATCH.py | 10 +++-- decorest/POST.py | 10 +++-- decorest/PUT.py | 10 +++-- decorest/__init__.py | 2 +- decorest/client.py | 40 +++++++++-------- decorest/decorators.py | 99 +++++++++++++++++++++++++++--------------- decorest/errors.py | 11 +++-- decorest/types.py | 29 +++++++++++++ decorest/utils.py | 13 ++++-- mypy.ini | 12 +++++ tests/httpbin_test.py | 2 +- tests/petstore_test.py | 2 +- 16 files changed, 188 insertions(+), 92 deletions(-) create mode 100644 mypy.ini diff --git a/decorest/DELETE.py b/decorest/DELETE.py index ecada8e..28ebced 100644 --- a/decorest/DELETE.py +++ b/decorest/DELETE.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """DELETE Http method decorator.""" - +import typing from functools import wraps from .decorators import HttpMethodDecorator, set_decor @@ -23,16 +23,18 @@ class DELETE(HttpMethodDecorator): """DELETE HTTP method decorator.""" - def __init__(self, path): + def __init__(self, path: str): """Initialize with endpoint relative path.""" super(DELETE, self).__init__(path) - def __call__(self, func): + def __call__(self, func: typing.Callable[..., None]) \ + -> typing.Callable[..., None]: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.DELETE) @wraps(func) - def delete_decorator(*args, **kwargs): + def delete_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: return super(DELETE, self).call(func, *args, **kwargs) return delete_decorator diff --git a/decorest/GET.py b/decorest/GET.py index a979706..17828ea 100644 --- a/decorest/GET.py +++ b/decorest/GET.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """GET Http method decorator.""" - +import typing from functools import wraps from .decorators import HttpMethodDecorator, set_decor @@ -23,16 +23,18 @@ class GET(HttpMethodDecorator): """GET HTTP method decorator.""" - def __init__(self, path): + def __init__(self, path: str): """Initialize with endpoint relative path.""" super(GET, self).__init__(path) - def __call__(self, func): + def __call__(self, func: typing.Callable[..., None]) \ + -> typing.Callable[..., None]: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.GET) @wraps(func) - def get_decorator(*args, **kwargs): + def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: return super(GET, self).call(func, *args, **kwargs) return get_decorator diff --git a/decorest/HEAD.py b/decorest/HEAD.py index f481a31..223e99f 100644 --- a/decorest/HEAD.py +++ b/decorest/HEAD.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """HEAD Http method decorator.""" - +import typing from functools import wraps from .decorators import HttpMethodDecorator, set_decor @@ -23,16 +23,18 @@ class HEAD(HttpMethodDecorator): """HEAD HTTP method decorator.""" - def __init__(self, path): + def __init__(self, path: str): """Initialize with endpoint relative path.""" super(HEAD, self).__init__(path) - def __call__(self, func): + def __call__(self, func: typing.Callable[..., None]) \ + -> typing.Callable[..., None]: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.HEAD) @wraps(func) - def options_decorator(*args, **kwargs): + def options_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: return super(HEAD, self).call(func, *args, **kwargs) return options_decorator diff --git a/decorest/OPTIONS.py b/decorest/OPTIONS.py index c9fa44c..f79299e 100644 --- a/decorest/OPTIONS.py +++ b/decorest/OPTIONS.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """OPTIONS Http method decorator.""" - +import typing from functools import wraps from .decorators import HttpMethodDecorator, set_decor @@ -23,16 +23,18 @@ class OPTIONS(HttpMethodDecorator): """OPTIONS HTTP method decorator.""" - def __init__(self, path): + def __init__(self, path: str): """Initialize with endpoint relative path.""" super(OPTIONS, self).__init__(path) - def __call__(self, func): + def __call__(self, func: typing.Callable[..., None]) \ + -> typing.Callable[..., None]: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.OPTIONS) @wraps(func) - def options_decorator(*args, **kwargs): + def options_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: return super(OPTIONS, self).call(func, *args, **kwargs) return options_decorator diff --git a/decorest/PATCH.py b/decorest/PATCH.py index f0dc242..ea83816 100644 --- a/decorest/PATCH.py +++ b/decorest/PATCH.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """PATCH Http method decorator.""" - +import typing from functools import wraps from .decorators import HttpMethodDecorator, set_decor @@ -23,16 +23,18 @@ class PATCH(HttpMethodDecorator): """PATCH HTTP method decorator.""" - def __init__(self, path): + def __init__(self, path: str): """Initialize with endpoint relative path.""" super(PATCH, self).__init__(path) - def __call__(self, func): + def __call__(self, func: typing.Callable[..., None]) \ + -> typing.Callable[..., None]: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.PATCH) @wraps(func) - def patch_decorator(*args, **kwargs): + def patch_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: return super(PATCH, self).call(func, *args, **kwargs) return patch_decorator diff --git a/decorest/POST.py b/decorest/POST.py index 84819b7..d402c1f 100644 --- a/decorest/POST.py +++ b/decorest/POST.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """POST Http method decorator.""" - +import typing from functools import wraps from .decorators import HttpMethodDecorator, set_decor @@ -23,16 +23,18 @@ class POST(HttpMethodDecorator): """POST HTTP method decorator.""" - def __init__(self, path): + def __init__(self, path: str): """Initialize with endpoint relative path.""" super(POST, self).__init__(path) - def __call__(self, func): + def __call__(self, func: typing.Callable[..., None]) \ + -> typing.Callable[..., None]: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.POST) @wraps(func) - def post_decorator(*args, **kwargs): + def post_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: return super(POST, self).call(func, *args, **kwargs) return post_decorator diff --git a/decorest/PUT.py b/decorest/PUT.py index 516fd59..ae43703 100644 --- a/decorest/PUT.py +++ b/decorest/PUT.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """PUT Http method decorator.""" - +import typing from functools import wraps from .decorators import HttpMethodDecorator, set_decor @@ -23,16 +23,18 @@ class PUT(HttpMethodDecorator): """PUT HTTP method decorator.""" - def __init__(self, path): + def __init__(self, path: str): """Initialize with endpoint relative path.""" super(PUT, self).__init__(path) - def __call__(self, func): + def __call__(self, func: typing.Callable[..., None]) \ + -> typing.Callable[..., None]: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.PUT) @wraps(func) - def put_decorator(*args, **kwargs): + def put_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: return super(PUT, self).call(func, *args, **kwargs) return put_decorator diff --git a/decorest/__init__.py b/decorest/__init__.py index 8c5f9fb..3c45952 100644 --- a/decorest/__init__.py +++ b/decorest/__init__.py @@ -35,4 +35,4 @@ 'multipart' ] -__version__ = "0.0.7" +__version__ = "0.1.0" diff --git a/decorest/client.py b/decorest/client.py index fa5340e..66cd3aa 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -19,22 +19,24 @@ This module contains also some enums for HTTP protocol. """ import logging as LOG +import typing import urllib.parse from .decorators import get_decor +from .types import AuthTypes, Backends, SessionTypes from .utils import normalize_url -class RestClientSession(object): +class RestClientSession: """Wrap a `requests` session for specific API client.""" - def __init__(self, client): + def __init__(self, client: "RestClient") -> None: """Initialize the session instance with a specific API client.""" - self.__client = client + self.__client: 'RestClient' = client # Create a session of type specific for given backend if client._backend() == 'requests': import requests - self.__session = requests.Session() + self.__session: SessionTypes = requests.Session() else: import httpx self.__session = httpx.Client() @@ -42,16 +44,15 @@ def __init__(self, client): if self.__client.auth is not None: self.__session.auth = self.__client.auth - def __enter__(self): + def __enter__(self) -> 'RestClientSession': """Context manager initialization.""" return self - def __exit__(self, *args): + def __exit__(self, *args: typing.Any) -> None: """Context manager destruction.""" self.__session.close() - return False - def __getattr__(self, name): + def __getattr__(self, name: str) -> typing.Any: """Forward any method invocation to actual client with session.""" if name == '_requests_session': return self.__session @@ -62,24 +63,27 @@ def __getattr__(self, name): if name == '_close': return self.__session.close - def invoker(*args, **kwargs): + def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: kwargs['__session'] = self.__session return getattr(self.__client, name)(*args, **kwargs) return invoker -class RestClient(object): +class RestClient: """Base class for decorest REST clients.""" - def __init__(self, endpoint=None, auth=None, backend='requests'): + def __init__(self, + endpoint: typing.Optional[str] = None, + auth: typing.Optional[AuthTypes] = None, + backend: Backends = 'requests'): """Initialize the client with optional endpoint.""" - self.endpoint = get_decor(self, 'endpoint') + self.endpoint = str(get_decor(self, 'endpoint')) self.auth = auth self._set_backend(backend) if endpoint is not None: self.endpoint = endpoint - def _session(self): + def _session(self) -> RestClientSession: """ Initialize RestClientSession session object. @@ -90,7 +94,7 @@ def _session(self): """ return RestClientSession(self) - def _set_auth(self, auth): + def _set_auth(self, auth: AuthTypes) -> None: """ Set a default authentication method for the client. @@ -99,7 +103,7 @@ def _set_auth(self, auth): """ self.auth = auth - def _auth(self): + def _auth(self) -> typing.Optional[AuthTypes]: """ Get authentication object. @@ -107,7 +111,7 @@ def _auth(self): """ return self.auth - def _set_backend(self, backend): + def _set_backend(self, backend: Backends) -> None: """ Set preferred backend. @@ -123,7 +127,7 @@ def _set_backend(self, backend): self.backend = backend - def _backend(self): + def _backend(self) -> str: """ Get active backend. @@ -131,7 +135,7 @@ def _backend(self): """ return self.backend - def build_request(self, path_components=[]): + def build_request(self, path_components: typing.List[str]) -> str: """ Build request. diff --git a/decorest/decorators.py b/decorest/decorators.py index 5bc715d..d30bd35 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -24,13 +24,15 @@ import json import logging as LOG import numbers +import typing from operator import methodcaller import requests from requests.structures import CaseInsensitiveDict +from . import types from .errors import HTTPErrorWrapper -from .types import HttpMethod, HttpStatus +from .types import ArgsDict, HttpMethod, HttpStatus from .utils import dict_from_args, merge_dicts, render_path DECOR_KEY = '__decorest__' @@ -41,7 +43,7 @@ ] -def set_decor(t, name, value): +def set_decor(t: typing.Any, name: str, value: typing.Any) -> None: """Decorate a function or class by storing the value under specific key.""" if hasattr(t, '__wrapped__') and hasattr(t.__wrapped__, DECOR_KEY): setattr(t, DECOR_KEY, t.__wrapped__.__decorest__) @@ -67,7 +69,7 @@ def set_decor(t, name, value): d[name] = value -def get_decor(t, name): +def get_decor(t: typing.Any, name: str) -> typing.Optional[typing.Any]: """ Retrieve a named decorator value from class or function. @@ -85,7 +87,9 @@ def get_decor(t, name): return None -def on(status, handler): +def on(status: typing.Union[types.ellipsis, int], + handler: typing.Callable[..., typing.Any]) \ + -> typing.Callable[..., typing.Any]: """ On status result handlers decorator. @@ -93,8 +97,9 @@ def on(status, handler): the sole parameter the requests response object. """ - def on_decorator(t): - if status is Ellipsis: + def on_decorator(t: typing.Callable[..., typing.Any]) \ + -> typing.Callable[..., typing.Any]: + if status is Ellipsis: # type: ignore set_decor(t, 'on', {HttpStatus.ANY: handler}) elif isinstance(status, numbers.Integral): set_decor(t, 'on', {status: handler}) @@ -105,10 +110,12 @@ def on_decorator(t): return on_decorator -def query(name, value=None): +def query(name: str, value: typing.Optional[str] = None) \ + -> typing.Callable[..., typing.Any]: """Query parameter decorator.""" - def query_decorator(t): + def query_decorator(t: typing.Callable[..., typing.Any]) \ + -> typing.Callable[..., typing.Any]: value_ = value if inspect.isclass(t): raise TypeError("@query decorator can only be " @@ -121,10 +128,12 @@ def query_decorator(t): return query_decorator -def form(name, value=None): +def form(name: str, value: typing.Optional[str] = None) \ + -> typing.Callable[..., typing.Any]: """Form parameter decorator.""" - def form_decorator(t): + def form_decorator(t: typing.Callable[..., typing.Any]) \ + -> typing.Callable[..., typing.Any]: value_ = value if inspect.isclass(t): raise TypeError("@form decorator can only be " @@ -137,10 +146,12 @@ def form_decorator(t): return form_decorator -def multipart(name, value=None): +def multipart(name: str, value: typing.Optional[str] = None) \ + -> typing.Callable[..., typing.Any]: """Multipart parameter decorator.""" - def multipart_decorator(t): + def multipart_decorator(t: typing.Callable[..., typing.Any]) \ + -> typing.Callable[..., typing.Any]: value_ = value if inspect.isclass(t): raise TypeError("@multipart decorator can only be " @@ -153,10 +164,12 @@ def multipart_decorator(t): return multipart_decorator -def header(name, value=None): +def header(name: str, value: typing.Optional[str] = None) \ + -> typing.Callable[..., typing.Any]: """Header class and method decorator.""" - def header_decorator(t): + def header_decorator(t: typing.Callable[..., typing.Any]) \ + -> typing.Callable[..., typing.Any]: value_ = value if not value_: value_ = name @@ -166,65 +179,75 @@ def header_decorator(t): return header_decorator -def endpoint(value): +def endpoint(value: str) -> typing.Callable[..., typing.Any]: """Endpoint class and method decorator.""" - def endpoint_decorator(t): + def endpoint_decorator(t: typing.Callable[..., typing.Any]) \ + -> typing.Callable[..., typing.Any]: set_decor(t, 'endpoint', value) return t return endpoint_decorator -def content(value): +def content(value: str) -> typing.Callable[..., typing.Any]: """Content-type header class and method decorator.""" - def content_decorator(t): - set_decor(t, 'header', CaseInsensitiveDict({'Content-Type': value})) + def content_decorator(t: typing.Callable[..., typing.Any]) \ + -> typing.Callable[..., typing.Any]: + set_decor(t, 'header', + CaseInsensitiveDict({'Content-Type': value})) return t return content_decorator -def accept(value): +def accept(value: str) -> typing.Callable[..., typing.Any]: """Accept header class and method decorator.""" - def accept_decorator(t): - set_decor(t, 'header', CaseInsensitiveDict({'Accept': value})) + def accept_decorator(t: typing.Callable[..., typing.Any]) \ + -> typing.Callable[..., typing.Any]: + set_decor(t, 'header', + CaseInsensitiveDict({'Accept': value})) return t return accept_decorator -def body(name, serializer=None): +def body(name: str, + serializer: typing.Optional[typing.Callable[..., typing.Any]] = None) \ + -> typing.Callable[..., typing.Any]: """ Body parameter decorator. Determines which method argument provides the body. """ - def body_decorator(t): + def body_decorator(t: typing.Callable[..., typing.Any]) \ + -> typing.Callable[..., typing.Any]: set_decor(t, 'body', (name, serializer)) return t return body_decorator -def timeout(value): +def timeout(value: float) -> typing.Callable[..., typing.Any]: """ Timeout parameter decorator. Specifies a default timeout value for method or entire API. """ - def timeout_decorator(t): + def timeout_decorator(t: typing.Callable[..., typing.Any]) \ + -> typing.Callable[..., typing.Any]: set_decor(t, 'timeout', value) return t return timeout_decorator -def stream(t): +def stream(t: typing.Callable[..., typing.Any]) \ + -> typing.Callable[..., typing.Any]: """ Stream parameter decorator, takes boolean True or False. @@ -235,14 +258,15 @@ def stream(t): return t -class HttpMethodDecorator(object): +class HttpMethodDecorator: """Abstract decorator for HTTP method decorators.""" - def __init__(self, path): + def __init__(self, path: str): """Initialize decorator with endpoint relative path.""" self.path_template = path - def call(self, func, *args, **kwargs): + def call(self, func: typing.Callable[..., typing.Any], + *args: typing.Any, **kwargs: typing.Any) -> typing.Any: """Execute the API HTTP request.""" http_method = get_decor(func, 'http_method') rest_client = args[0] @@ -450,7 +474,7 @@ def call(self, func, *args, **kwargs): result = self.__dispatch( execution_context, http_method, kwargs, req) except Exception as e: - raise HTTPErrorWrapper(e) + raise HTTPErrorWrapper(typing.cast(types.HTTPErrors, e)) if on_handlers and result.status_code in on_handlers: # Use a registered handler for the returned status code @@ -468,7 +492,7 @@ def call(self, func, *args, **kwargs): try: result.raise_for_status() except Exception as e: - raise HTTPErrorWrapper(e) + raise HTTPErrorWrapper(typing.cast(types.HTTPErrors, e)) if result.text: content_type = result.headers.get('content-type') @@ -481,7 +505,9 @@ def call(self, func, *args, **kwargs): return None - def __dispatch(self, execution_context, http_method, kwargs, req): + def __dispatch(self, execution_context: typing.Callable[..., typing.Any], + http_method: typing.Union[str, HttpMethod], + kwargs: ArgsDict, req: str) -> typing.Any: """ Dispatch HTTP method based on HTTPMethod enum type. @@ -498,7 +524,8 @@ def __dispatch(self, execution_context, http_method, kwargs, req): return methodcaller(method, req, **kwargs)(execution_context) - def __validate_decor(self, decor, kwargs, cls): + def __validate_decor(self, decor: str, kwargs: ArgsDict, + cls: typing.Type[typing.Any]) -> None: """ Ensure kwargs contain decor with specific type. @@ -512,7 +539,9 @@ def __validate_decor(self, decor, kwargs, cls): "{} value must be an instance of {}".format( decor, cls.__name__)) - def __merge_args(self, args_dict, func, decor): + def __merge_args(self, args_dict: ArgsDict, + func: typing.Callable[..., typing.Any], decor: str) \ + -> ArgsDict: """ Match named arguments from method call. diff --git a/decorest/errors.py b/decorest/errors.py index e3f49c8..7884d24 100644 --- a/decorest/errors.py +++ b/decorest/errors.py @@ -14,6 +14,9 @@ # See the License for the specific language governing permissions and # limitations under the License. """Defines various error classes.""" +import typing + +from decorest.types import HTTPErrors class HTTPErrorWrapper(Exception): @@ -23,7 +26,7 @@ class HTTPErrorWrapper(Exception): This error class wraps HTTP errors from different supported backends, i.e. requests and httpx. """ - def __init__(self, e): + def __init__(self, e: HTTPErrors): """Construct HTTPErrorWrapper. Accepts a wrapped error. @@ -32,14 +35,14 @@ def __init__(self, e): super(Exception, self).__init__(self) @property - def response(self): + def response(self) -> typing.Any: """Return wrapped response.""" return self.wrapped.response - def __repr__(self): + def __repr__(self) -> str: """Return wrapped representation.""" return self.wrapped.__repr__() - def __str__(self): + def __str__(self) -> str: """Return wrapped str representation.""" return self.wrapped.__str__() diff --git a/decorest/types.py b/decorest/types.py index 033ab0b..4b7ad5e 100644 --- a/decorest/types.py +++ b/decorest/types.py @@ -16,6 +16,8 @@ """Various types related to HTTP and REST.""" import enum +import typing + DEnum = enum.Enum DIntEnum = enum.IntEnum @@ -41,3 +43,30 @@ class HttpStatus(DIntEnum): CLIENT_ERROR = 4, SERVER_ERROR = 5, ANY = 999 # Same as Ellipsis '...' + + +if typing.TYPE_CHECKING: + # If not available, these imports will be ignored through settings + # in mypy.ini + import requests + import httpx + +ArgsDict = typing.Dict[str, typing.Any] +Backends = typing.Literal['requests', 'httpx'] +AuthTypes = typing.Union['requests.auth.AuthBase', 'httpx.Auth'] +SessionTypes = typing.Union['requests.Session', 'httpx.Client'] +HTTPErrors = typing.Union['requests.HTTPError', 'httpx.HTTPStatusError'] + + +if typing.TYPE_CHECKING: + class ellipsis(enum.Enum): # noqa N801 + """ + Ellipsis type for typechecking. + + A workaround to enable specifying ellipsis as possible type + for the 'on' decorator. + """ + Ellipsis = "..." + Ellipsis = ellipsis.Ellipsis +else: + ellipsis = type(Ellipsis) diff --git a/decorest/utils.py b/decorest/utils.py index f88bb09..6848fa0 100644 --- a/decorest/utils.py +++ b/decorest/utils.py @@ -18,9 +18,12 @@ import inspect import logging as LOG import re +import typing +from decorest.types import ArgsDict -def render_path(path, args): + +def render_path(path: str, args: ArgsDict) -> str: """Render REST path from *args.""" LOG.debug('RENDERING PATH FROM: %s, %s', path, args) result = path @@ -34,7 +37,8 @@ def render_path(path, args): return result -def dict_from_args(func, *args): +def dict_from_args(func: typing.Callable[..., typing.Any], + *args: typing.Any) -> ArgsDict: """Convert function arguments to a dictionary.""" result = {} @@ -55,7 +59,8 @@ def dict_from_args(func, *args): return result -def merge_dicts(*dict_args): +def merge_dicts(*dict_args: typing.Any) \ + -> typing.Dict[typing.Any, typing.Any]: """ Merge all dicts passed as arguments, skips None objects. @@ -73,7 +78,7 @@ def merge_dicts(*dict_args): return result -def normalize_url(url): +def normalize_url(url: str) -> str: """Make sure the url is in correct form.""" result = url diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..ac88ebb --- /dev/null +++ b/mypy.ini @@ -0,0 +1,12 @@ +[mypy] +warn_return_any = True +warn_unused_configs = True + +[mypy-requests.*] +ignore_missing_imports = True + +[mypy-httpx.*] +ignore_missing_imports = True + +[mypy-requests_toolbelt.*] +ignore_missing_imports = True diff --git a/tests/httpbin_test.py b/tests/httpbin_test.py index d5320f8..2569972 100644 --- a/tests/httpbin_test.py +++ b/tests/httpbin_test.py @@ -34,7 +34,7 @@ from httpbin.httpbin_client import HttpBinClient, parse_image -def client(backend): +def client(backend: str) -> HttpBinClient: # Give Docker and HTTPBin some time to spin up time.sleep(2) diff --git a/tests/petstore_test.py b/tests/petstore_test.py index 112bcc9..2b26b82 100644 --- a/tests/petstore_test.py +++ b/tests/petstore_test.py @@ -26,7 +26,7 @@ from swagger_petstore.petstore_client import PetstoreClient -def client(backend): +def client(backend: str) -> PetstoreClient: # Give Docker and Swagger Petstore some time to spin up time.sleep(2) host = "localhost" From 9923eefbbda69053b294e1d19469a4b8cf06081c Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Mon, 13 Dec 2021 22:41:58 +0100 Subject: [PATCH 04/48] Added generic types to decorators --- decorest/DELETE.py | 7 +- decorest/GET.py | 7 +- decorest/HEAD.py | 9 +- decorest/OPTIONS.py | 7 +- decorest/PATCH.py | 7 +- decorest/POST.py | 7 +- decorest/PUT.py | 7 +- decorest/client.py | 2 +- decorest/decorators.py | 149 +++++++++++----- decorest/errors.py | 2 +- decorest/types.py | 3 + examples/__init__.py | 16 ++ .../petstore_client_with_typing.py | 168 ++++++++++++++++++ mypy.ini | 3 + tests/decorators_tests.py | 88 +++++---- tests/petstore_test_with_typing.py | 154 ++++++++++++++++ 16 files changed, 521 insertions(+), 115 deletions(-) create mode 100644 examples/__init__.py create mode 100644 examples/swagger_petstore/petstore_client_with_typing.py create mode 100644 tests/petstore_test_with_typing.py diff --git a/decorest/DELETE.py b/decorest/DELETE.py index 28ebced..b0c6cdf 100644 --- a/decorest/DELETE.py +++ b/decorest/DELETE.py @@ -18,7 +18,7 @@ from functools import wraps from .decorators import HttpMethodDecorator, set_decor -from .types import HttpMethod +from .types import HttpMethod, TDecor class DELETE(HttpMethodDecorator): @@ -27,8 +27,7 @@ def __init__(self, path: str): """Initialize with endpoint relative path.""" super(DELETE, self).__init__(path) - def __call__(self, func: typing.Callable[..., None]) \ - -> typing.Callable[..., None]: + def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.DELETE) @@ -37,4 +36,4 @@ def delete_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: return super(DELETE, self).call(func, *args, **kwargs) - return delete_decorator + return typing.cast(TDecor, delete_decorator) diff --git a/decorest/GET.py b/decorest/GET.py index 17828ea..1a13daf 100644 --- a/decorest/GET.py +++ b/decorest/GET.py @@ -18,7 +18,7 @@ from functools import wraps from .decorators import HttpMethodDecorator, set_decor -from .types import HttpMethod +from .types import HttpMethod, TDecor class GET(HttpMethodDecorator): @@ -27,8 +27,7 @@ def __init__(self, path: str): """Initialize with endpoint relative path.""" super(GET, self).__init__(path) - def __call__(self, func: typing.Callable[..., None]) \ - -> typing.Callable[..., None]: + def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.GET) @@ -37,4 +36,4 @@ def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: return super(GET, self).call(func, *args, **kwargs) - return get_decorator + return typing.cast(TDecor, get_decorator) diff --git a/decorest/HEAD.py b/decorest/HEAD.py index 223e99f..595055f 100644 --- a/decorest/HEAD.py +++ b/decorest/HEAD.py @@ -18,7 +18,7 @@ from functools import wraps from .decorators import HttpMethodDecorator, set_decor -from .types import HttpMethod +from .types import HttpMethod, TDecor class HEAD(HttpMethodDecorator): @@ -27,14 +27,13 @@ def __init__(self, path: str): """Initialize with endpoint relative path.""" super(HEAD, self).__init__(path) - def __call__(self, func: typing.Callable[..., None]) \ - -> typing.Callable[..., None]: + def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.HEAD) @wraps(func) - def options_decorator(*args: typing.Any, **kwargs: typing.Any) \ + def head_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: return super(HEAD, self).call(func, *args, **kwargs) - return options_decorator + return typing.cast(TDecor, head_decorator) diff --git a/decorest/OPTIONS.py b/decorest/OPTIONS.py index f79299e..35777d7 100644 --- a/decorest/OPTIONS.py +++ b/decorest/OPTIONS.py @@ -18,7 +18,7 @@ from functools import wraps from .decorators import HttpMethodDecorator, set_decor -from .types import HttpMethod +from .types import HttpMethod, TDecor class OPTIONS(HttpMethodDecorator): @@ -27,8 +27,7 @@ def __init__(self, path: str): """Initialize with endpoint relative path.""" super(OPTIONS, self).__init__(path) - def __call__(self, func: typing.Callable[..., None]) \ - -> typing.Callable[..., None]: + def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.OPTIONS) @@ -37,4 +36,4 @@ def options_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: return super(OPTIONS, self).call(func, *args, **kwargs) - return options_decorator + return typing.cast(TDecor, options_decorator) diff --git a/decorest/PATCH.py b/decorest/PATCH.py index ea83816..9606cd1 100644 --- a/decorest/PATCH.py +++ b/decorest/PATCH.py @@ -18,7 +18,7 @@ from functools import wraps from .decorators import HttpMethodDecorator, set_decor -from .types import HttpMethod +from .types import HttpMethod, TDecor class PATCH(HttpMethodDecorator): @@ -27,8 +27,7 @@ def __init__(self, path: str): """Initialize with endpoint relative path.""" super(PATCH, self).__init__(path) - def __call__(self, func: typing.Callable[..., None]) \ - -> typing.Callable[..., None]: + def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.PATCH) @@ -37,4 +36,4 @@ def patch_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: return super(PATCH, self).call(func, *args, **kwargs) - return patch_decorator + return typing.cast(TDecor, patch_decorator) diff --git a/decorest/POST.py b/decorest/POST.py index d402c1f..37d3776 100644 --- a/decorest/POST.py +++ b/decorest/POST.py @@ -18,7 +18,7 @@ from functools import wraps from .decorators import HttpMethodDecorator, set_decor -from .types import HttpMethod +from .types import HttpMethod, TDecor class POST(HttpMethodDecorator): @@ -27,8 +27,7 @@ def __init__(self, path: str): """Initialize with endpoint relative path.""" super(POST, self).__init__(path) - def __call__(self, func: typing.Callable[..., None]) \ - -> typing.Callable[..., None]: + def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.POST) @@ -37,4 +36,4 @@ def post_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: return super(POST, self).call(func, *args, **kwargs) - return post_decorator + return typing.cast(TDecor, post_decorator) diff --git a/decorest/PUT.py b/decorest/PUT.py index ae43703..c33d7e1 100644 --- a/decorest/PUT.py +++ b/decorest/PUT.py @@ -18,7 +18,7 @@ from functools import wraps from .decorators import HttpMethodDecorator, set_decor -from .types import HttpMethod +from .types import HttpMethod, TDecor class PUT(HttpMethodDecorator): @@ -27,8 +27,7 @@ def __init__(self, path: str): """Initialize with endpoint relative path.""" super(PUT, self).__init__(path) - def __call__(self, func: typing.Callable[..., None]) \ - -> typing.Callable[..., None]: + def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.PUT) @@ -37,4 +36,4 @@ def put_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: return super(PUT, self).call(func, *args, **kwargs) - return put_decorator + return typing.cast(TDecor, put_decorator) diff --git a/decorest/client.py b/decorest/client.py index 66cd3aa..14abc99 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -29,7 +29,7 @@ class RestClientSession: """Wrap a `requests` session for specific API client.""" - def __init__(self, client: "RestClient") -> None: + def __init__(self, client: 'RestClient') -> None: """Initialize the session instance with a specific API client.""" self.__client: 'RestClient' = client diff --git a/decorest/decorators.py b/decorest/decorators.py index d30bd35..206cb9c 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -32,14 +32,14 @@ from . import types from .errors import HTTPErrorWrapper -from .types import ArgsDict, HttpMethod, HttpStatus +from .types import ArgsDict, HttpMethod, HttpStatus, TDecor from .utils import dict_from_args, merge_dicts, render_path DECOR_KEY = '__decorest__' DECOR_LIST = [ 'header', 'query', 'form', 'multipart', 'on', 'accept', 'content', - 'timeout', 'stream', 'body' + 'timeout', 'stream', 'body', 'endpoint' ] @@ -87,9 +87,79 @@ def get_decor(t: typing.Any, name: str) -> typing.Optional[typing.Any]: return None +def get_method_decor(t: typing.Any) -> HttpMethod: + """Return http method decor value.""" + return typing.cast(HttpMethod, get_decor(t, 'http_method')) + + +def get_header_decor(t: typing.Any) -> typing.Optional[typing.Dict[str, str]]: + """Return header decor values.""" + return typing.cast(typing.Optional[typing.Dict[str, str]], + get_decor(t, 'header')) + + +def get_query_decor(t: typing.Any) -> typing.Optional[typing.Dict[str, str]]: + """Return query decor values.""" + return typing.cast(typing.Optional[typing.Dict[str, str]], + get_decor(t, 'query')) + + +def get_form_decor(t: typing.Any) -> typing.Optional[typing.Dict[str, str]]: + """Return form decor values.""" + return typing.cast(typing.Optional[typing.Dict[str, str]], + get_decor(t, 'form')) + + +def get_multipart_decor(t: typing.Any) \ + -> typing.Optional[typing.Dict[str, str]]: + """Return multipart decor values.""" + return typing.cast(typing.Optional[typing.Dict[str, str]], + get_decor(t, 'multipart')) + + +def get_on_decor(t: typing.Any) \ + -> typing.Optional[typing.Dict[int, typing.Any]]: + """Return on decor values.""" + return typing.cast(typing.Optional[typing.Dict[int, typing.Any]], + get_decor(t, 'on')) + + +def get_accept_decor(t: typing.Any) -> typing.Optional[str]: + """Return accept decor value.""" + return typing.cast(typing.Optional[str], + get_decor(t, 'accept')) + + +def get_content_decor(t: typing.Any) -> typing.Optional[str]: + """Return content-type decor value.""" + return typing.cast(typing.Optional[str], + get_decor(t, 'content')) + + +def get_timeout_decor(t: typing.Any) -> typing.Optional[numbers.Real]: + """Return timeout decor value.""" + return typing.cast(typing.Optional[numbers.Real], + get_decor(t, 'timeout')) + + +def get_stream_decor(t: typing.Any) -> bool: + """Return stream decor value.""" + return typing.cast(bool, get_decor(t, 'stream')) + + +def get_body_decor(t: typing.Any) -> typing.Optional[typing.Any]: + """Return body decor value.""" + return get_decor(t, 'body') + + +def get_endpoint_decor(t: typing.Any) -> typing.Optional[str]: + """Return endpoint decor value.""" + return typing.cast(typing.Optional[str], get_decor(t, 'endpoint')) + + def on(status: typing.Union[types.ellipsis, int], handler: typing.Callable[..., typing.Any]) \ - -> typing.Callable[..., typing.Any]: + -> typing.Callable[[TDecor], TDecor]: """ On status result handlers decorator. @@ -97,8 +167,7 @@ def on(status: typing.Union[types.ellipsis, int], the sole parameter the requests response object. """ - def on_decorator(t: typing.Callable[..., typing.Any]) \ - -> typing.Callable[..., typing.Any]: + def on_decorator(t: TDecor) -> TDecor: if status is Ellipsis: # type: ignore set_decor(t, 'on', {HttpStatus.ANY: handler}) elif isinstance(status, numbers.Integral): @@ -111,11 +180,10 @@ def on_decorator(t: typing.Callable[..., typing.Any]) \ def query(name: str, value: typing.Optional[str] = None) \ - -> typing.Callable[..., typing.Any]: + -> typing.Callable[[TDecor], TDecor]: """Query parameter decorator.""" - def query_decorator(t: typing.Callable[..., typing.Any]) \ - -> typing.Callable[..., typing.Any]: + def query_decorator(t: TDecor) -> TDecor: value_ = value if inspect.isclass(t): raise TypeError("@query decorator can only be " @@ -129,11 +197,10 @@ def query_decorator(t: typing.Callable[..., typing.Any]) \ def form(name: str, value: typing.Optional[str] = None) \ - -> typing.Callable[..., typing.Any]: + -> typing.Callable[[TDecor], TDecor]: """Form parameter decorator.""" - def form_decorator(t: typing.Callable[..., typing.Any]) \ - -> typing.Callable[..., typing.Any]: + def form_decorator(t: TDecor) -> TDecor: value_ = value if inspect.isclass(t): raise TypeError("@form decorator can only be " @@ -147,11 +214,10 @@ def form_decorator(t: typing.Callable[..., typing.Any]) \ def multipart(name: str, value: typing.Optional[str] = None) \ - -> typing.Callable[..., typing.Any]: + -> typing.Callable[[TDecor], TDecor]: """Multipart parameter decorator.""" - def multipart_decorator(t: typing.Callable[..., typing.Any]) \ - -> typing.Callable[..., typing.Any]: + def multipart_decorator(t: TDecor) -> TDecor: value_ = value if inspect.isclass(t): raise TypeError("@multipart decorator can only be " @@ -165,11 +231,10 @@ def multipart_decorator(t: typing.Callable[..., typing.Any]) \ def header(name: str, value: typing.Optional[str] = None) \ - -> typing.Callable[..., typing.Any]: + -> typing.Callable[[TDecor], TDecor]: """Header class and method decorator.""" - def header_decorator(t: typing.Callable[..., typing.Any]) \ - -> typing.Callable[..., typing.Any]: + def header_decorator(t: TDecor) -> TDecor: value_ = value if not value_: value_ = name @@ -179,22 +244,20 @@ def header_decorator(t: typing.Callable[..., typing.Any]) \ return header_decorator -def endpoint(value: str) -> typing.Callable[..., typing.Any]: +def endpoint(value: str) -> typing.Callable[[TDecor], TDecor]: """Endpoint class and method decorator.""" - def endpoint_decorator(t: typing.Callable[..., typing.Any]) \ - -> typing.Callable[..., typing.Any]: + def endpoint_decorator(t: TDecor) -> TDecor: set_decor(t, 'endpoint', value) return t return endpoint_decorator -def content(value: str) -> typing.Callable[..., typing.Any]: +def content(value: str) -> typing.Callable[[TDecor], TDecor]: """Content-type header class and method decorator.""" - def content_decorator(t: typing.Callable[..., typing.Any]) \ - -> typing.Callable[..., typing.Any]: + def content_decorator(t: TDecor) -> TDecor: set_decor(t, 'header', CaseInsensitiveDict({'Content-Type': value})) return t @@ -202,11 +265,10 @@ def content_decorator(t: typing.Callable[..., typing.Any]) \ return content_decorator -def accept(value: str) -> typing.Callable[..., typing.Any]: +def accept(value: str) -> typing.Callable[[TDecor], TDecor]: """Accept header class and method decorator.""" - def accept_decorator(t: typing.Callable[..., typing.Any]) \ - -> typing.Callable[..., typing.Any]: + def accept_decorator(t: TDecor) -> TDecor: set_decor(t, 'header', CaseInsensitiveDict({'Accept': value})) return t @@ -216,38 +278,35 @@ def accept_decorator(t: typing.Callable[..., typing.Any]) \ def body(name: str, serializer: typing.Optional[typing.Callable[..., typing.Any]] = None) \ - -> typing.Callable[..., typing.Any]: + -> typing.Callable[[TDecor], TDecor]: """ Body parameter decorator. Determines which method argument provides the body. """ - def body_decorator(t: typing.Callable[..., typing.Any]) \ - -> typing.Callable[..., typing.Any]: + def body_decorator(t: TDecor) -> TDecor: set_decor(t, 'body', (name, serializer)) return t return body_decorator -def timeout(value: float) -> typing.Callable[..., typing.Any]: +def timeout(value: float) -> typing.Callable[[TDecor], TDecor]: """ Timeout parameter decorator. Specifies a default timeout value for method or entire API. """ - def timeout_decorator(t: typing.Callable[..., typing.Any]) \ - -> typing.Callable[..., typing.Any]: + def timeout_decorator(t: TDecor) -> TDecor: set_decor(t, 'timeout', value) return t return timeout_decorator -def stream(t: typing.Callable[..., typing.Any]) \ - -> typing.Callable[..., typing.Any]: +def stream(t: TDecor) -> TDecor: """ Stream parameter decorator, takes boolean True or False. @@ -268,7 +327,7 @@ def __init__(self, path: str): def call(self, func: typing.Callable[..., typing.Any], *args: typing.Any, **kwargs: typing.Any) -> typing.Any: """Execute the API HTTP request.""" - http_method = get_decor(func, 'http_method') + http_method = get_method_decor(func) rest_client = args[0] args_dict = dict_from_args(func, *args) req_path = render_path(self.path_template, args_dict) @@ -284,13 +343,13 @@ def call(self, func: typing.Callable[..., typing.Any], form_parameters = self.__merge_args(args_dict, func, 'form') multipart_parameters = self.__merge_args(args_dict, func, 'multipart') header_parameters = merge_dicts( - get_decor(rest_client.__class__, 'header'), + get_header_decor(rest_client.__class__), self.__merge_args(args_dict, func, 'header')) # Merge header parameters with default values, treat header # decorators with 2 params as default values only if they # don't match the function argument names - func_header_decors = get_decor(func, 'header') + func_header_decors = get_header_decor(func) if func_header_decors: for key in func_header_decors.keys(): if not func_header_decors[key] in args_dict: @@ -298,7 +357,7 @@ def call(self, func: typing.Callable[..., typing.Any], # Get body content from positional arguments if one is specified # using @body decorator - body_parameter = get_decor(func, 'body') + body_parameter = get_body_decor(func) body_content = None if body_parameter: body_content = args_dict.get(body_parameter[0]) @@ -311,18 +370,18 @@ def call(self, func: typing.Callable[..., typing.Any], auth = rest_client._auth() # Get status handlers - on_handlers = merge_dicts(get_decor(rest_client.__class__, 'on'), - get_decor(func, 'on')) + on_handlers = merge_dicts(get_on_decor(rest_client.__class__), + get_on_decor(func)) # Get timeout - request_timeout = get_decor(rest_client.__class__, 'timeout') - if get_decor(func, 'timeout'): - request_timeout = get_decor(func, 'timeout') + request_timeout = get_timeout_decor(rest_client.__class__) + if get_timeout_decor(func): + request_timeout = get_timeout_decor(func) # Check if stream is requested for this call - is_stream = get_decor(func, 'stream') + is_stream = get_stream_decor(func) if is_stream is None: - is_stream = get_decor(rest_client.__class__, 'stream') + is_stream = get_stream_decor(rest_client.__class__) # # If the kwargs contains any decorest decorators that should diff --git a/decorest/errors.py b/decorest/errors.py index 7884d24..56d8b8e 100644 --- a/decorest/errors.py +++ b/decorest/errors.py @@ -31,7 +31,7 @@ def __init__(self, e: HTTPErrors): Accepts a wrapped error. """ - self.wrapped = e + self.wrapped: HTTPErrors = e super(Exception, self).__init__(self) @property diff --git a/decorest/types.py b/decorest/types.py index 4b7ad5e..a2e8e77 100644 --- a/decorest/types.py +++ b/decorest/types.py @@ -57,6 +57,9 @@ class HttpStatus(DIntEnum): SessionTypes = typing.Union['requests.Session', 'httpx.Client'] HTTPErrors = typing.Union['requests.HTTPError', 'httpx.HTTPStatusError'] +# _TDecor = typing.Callable[..., typing.Any] +TDecor = typing.TypeVar('TDecor', bound=typing.Callable[..., typing.Any]) + if typing.TYPE_CHECKING: class ellipsis(enum.Enum): # noqa N801 diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..8e6c3d4 --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2018-2021 Bartosz Kryza +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""decorest example client module.""" diff --git a/examples/swagger_petstore/petstore_client_with_typing.py b/examples/swagger_petstore/petstore_client_with_typing.py new file mode 100644 index 0000000..4451a19 --- /dev/null +++ b/examples/swagger_petstore/petstore_client_with_typing.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2018-2021 Bartosz Kryza +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Example decorest based client to Swagger Petstore sample service, +including typing hints. + + http://petstore.swagger.io/ + +""" + +import json +import typing +import xml.etree.ElementTree as ET + +from decorest import DELETE, GET, POST, PUT +from decorest import HttpStatus, RestClient +from decorest import __version__ +from decorest import accept, body, content, endpoint, header, on, query + +JsonDictType = typing.Dict[str, typing.Any] + + +@header('user-agent', 'decorest/{v}'.format(v=__version__)) +@content('application/json') +@accept('application/json') +@endpoint('http://petstore.example.com') +class PetAPI(RestClient): + """Everything about your Pets.""" + + @POST('pet') + @content('application/json') + @accept('application/json') + @body('pet', lambda p: json.dumps(p)) + def add_pet(self, pet: JsonDictType) -> None: + """Add a new pet to the store.""" + + @PUT('pet') + @body('pet') + def update_pet(self, pet: JsonDictType) -> None: + """Update an existing pet.""" + + @GET('pet/findByStatus') + @on(200, lambda r: r.json()) + @on(HttpStatus.ANY, lambda r: r.raise_for_status()) + def find_pet_by_status(self) -> typing.List[JsonDictType]: + """Find Pets by status.""" + + @GET('pet/findByStatus') + @accept('application/xml') + @on(200, lambda r: ET.fromstring(r.text)) + def find_pet_by_status_xml(self) -> ET.Element: + """Find Pets by status.""" + + @GET('pet/{pet_id}') + def find_pet_by_id(self, pet_id: str) -> JsonDictType: + """Find Pet by ID.""" + + @POST('pet/{pet_id}') + @body('pet', lambda p: json.dumps(p)) + def update_pet_by_id(self, pet_id: str, pet: JsonDictType) -> None: + """Update a pet in the store with form data.""" + + @DELETE('pet/{pet_id}') + def delete_pet(self, pet_id: str) -> None: + """Delete a pet.""" + + @POST('pet/{pet_id}/uploadImage') + @body('pet', lambda p: json.dumps(p)) + def upload_pet_image(self, pet_id: str, image: typing.Any) -> None: + """Upload an image.""" + + +class StoreAPI(RestClient): + """Access to Petstore orders.""" + + @GET('store/inventory') + def get_inventory(self) -> JsonDictType: + """Return pet inventories by status.""" + + @POST('store/order') + @body('order', lambda o: json.dumps(o)) + def place_order(self, order: JsonDictType) -> None: + """Place an order for a pet.""" + + @GET('store/order/{order_id}') + def get_order(self, order_id: str) -> JsonDictType: + """Find purchase order by ID.""" + + @DELETE('store/order/{order_id}') + def delete_order(self, order_id: str) -> None: + """Delete purchase order by ID.""" + + +class UserAPI(RestClient): + """Operations about user.""" + + @POST('user') + @body('user', lambda o: json.dumps(o)) + @on(200, lambda r: True) + def create_user(self, user: JsonDictType) -> None: + """Create user.""" + + @POST('user/createWithArray') + @body('user', lambda o: json.dumps(o)) + def create_users_from_array(self, user: JsonDictType) -> None: + """Create list of users with given input array.""" + + @POST('user/createWithList') + @body('user', lambda o: json.dumps(o)) + def create_users_from_list(self, user: JsonDictType) -> None: + """Create list of users with given input array.""" + + @GET('user/login') + @query('username') + @query('password') + @on(200, lambda r: r.content) + def login(self, username: str, password: str) -> JsonDictType: + """Log user into the system.""" + + @GET('user/logout') + def logout(self) -> JsonDictType: + """Log out current logged in user session.""" + + @GET('user/{username}') + def get_user(self, username: str) -> JsonDictType: + """Get user by user name.""" + + @PUT('user/{username}') + @body('user', lambda o: json.dumps(o)) + def update_user(self, username: str, user: JsonDictType) -> JsonDictType: + """Update user.""" + + @DELETE('user/{username}') + def delete_user(self, username: str) -> None: + """Delete user.""" + + +class PetstoreClientWithTyping(PetAPI, StoreAPI, UserAPI): + """Swagger Petstore client.""" + + +# +# These checks only validate that typing works, they are not meant +# to be executed +# +client = PetstoreClientWithTyping('http://example.com', backend='requests') + +assert client.add_pet({'a': {'b': 'c'}}) is None +assert client.update_pet({'a': {'b': 'c'}}) is None +assert client.find_pet_by_status() == [{'a': {'b': 'c'}}] +assert client.find_pet_by_status_xml() == ET.Element('pet') +assert client.find_pet_by_id('123') == {'a': {'b': 'c'}} +assert client.update_pet_by_id('123', {'a': {'b': 'c'}}) is None +assert client.delete_pet('123') is None +assert client.upload_pet_image('123', ) is None \ No newline at end of file diff --git a/mypy.ini b/mypy.ini index ac88ebb..b6b414c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -10,3 +10,6 @@ ignore_missing_imports = True [mypy-requests_toolbelt.*] ignore_missing_imports = True + +[mypy-pytest.*] +ignore_missing_imports = True \ No newline at end of file diff --git a/tests/decorators_tests.py b/tests/decorators_tests.py index fde35d6..3731b52 100644 --- a/tests/decorators_tests.py +++ b/tests/decorators_tests.py @@ -13,6 +13,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import typing import pytest import functools @@ -20,7 +21,8 @@ from decorest import RestClient, HttpMethod from decorest import GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS from decorest import accept, content, endpoint, form, header, query, stream -from decorest.decorators import get_decor +from decorest.decorators import get_decor, get_header_decor, get_endpoint_decor, get_form_decor, get_query_decor, \ + get_stream_decor, get_on_decor, get_method_decor @accept('application/json') @@ -31,115 +33,123 @@ class DogClient(RestClient): """DogClient client""" @GET('breed/{breed_name}/list') - def list_subbreeds(self, breed_name): + def list_subbreeds(self, breed_name: str) -> typing.Any: """List all sub-breeds""" - def plain_list_subbreeds(self, breed_name): + def plain_list_subbreeds(self, breed_name: str) -> typing.Any: """List all sub-breeds""" @GET('breed') @query('a') @query('b') @query('c', 'd') - def queries(self, a, b, c=2): + def queries(self, a: str, b: str, c: int = 2) -> typing.Any: """So many queries""" - def plain_queries(self, a, b, c=2): + def plain_queries(self, a: str, b: str, c: int = 2) -> typing.Any: """So many queries""" @GET('breed') @content('application/json') @header('A', 'B') @accept('application/xml') - def headers(self, a, b, c): + def headers(self, a: str, b: str, c: str) -> typing.Any: """Headers""" - def plain_headers(self, a, b, c): + def plain_headers(self, a: str, b: str, c: str) -> None: """Headers""" @GET('get') - def get(self, a): + def get(self, a: str) -> typing.Any: """Get something""" @POST('post') - def post(self, a): + def post(self, a: str) -> None: """Post something""" @POST('post') @form('key1') @form('key2', 'keyTwo') - def post_form(self, key1, key2): + def post_form(self, key1: str, key2: str) -> None: """Post 2 keys""" @PUT('put') - def put(self, a): + def put(self, a: str) -> None: """Put something""" @PATCH('patch') - def patch(self, a): + def patch(self, a: str) -> None: """Patch something""" @DELETE('delete') - def delete(self, a): + def delete(self, a: str) -> None: """Delete something""" @HEAD('head') - def head(self, a): + def head(self, a: str) -> typing.Any: """Heads up""" @OPTIONS('options') - def options(self, a): + def options(self, a: str) -> typing.Any: """What can I do?""" @GET('stream/{n}/{m}') @stream @query('size') @query('offset', 'off') - def stream_range(self, n, m, size, offset): + def stream_range(self, n: int, m: int, + size: int, offset: int) -> typing.Any: """Get data range""" - def plain_stream_range(self, n, m, size, offset): + def plain_stream_range(self, n: int, m: int, + size: int, offset: int) -> typing.Any: """Get data range""" -def test_set_decor(): +def test_set_decor() -> None: """ Check that decorators store proper values in the decorated class and methods. """ - assert get_decor(DogClient, 'header')['Accept'] == 'application/json' - assert get_decor(DogClient, 'header')['content-Type'] == 'application/xml' - assert get_decor(DogClient, 'header')['x-auth-key'] == 'ABCD' - assert get_decor(DogClient, 'endpoint') == 'https://dog.ceo/' - - assert get_decor(DogClient.get, 'http_method') == HttpMethod.GET - assert get_decor(DogClient.post, 'http_method') == HttpMethod.POST - assert get_decor(DogClient.put, 'http_method') == HttpMethod.PUT - assert get_decor(DogClient.patch, 'http_method') == HttpMethod.PATCH - assert get_decor(DogClient.delete, 'http_method') == HttpMethod.DELETE - assert get_decor(DogClient.head, 'http_method') == HttpMethod.HEAD - assert get_decor(DogClient.options, 'http_method') == HttpMethod.OPTIONS - assert get_decor(DogClient.stream_range, 'http_method') == HttpMethod.GET - - assert get_decor(DogClient.post_form, 'form') == { + assert get_on_decor(DogClient) is None + + headers = get_header_decor(DogClient) + assert headers is not None + assert headers['Accept'] == 'application/json' + assert headers['accept'] == 'application/json' + assert headers['content-Type'] == 'application/xml' + assert headers['x-auth-key'] == 'ABCD' + + assert get_endpoint_decor(DogClient) == 'https://dog.ceo/' + + assert get_method_decor(DogClient.get) == HttpMethod.GET + assert get_method_decor(DogClient.post) == HttpMethod.POST + assert get_method_decor(DogClient.put) == HttpMethod.PUT + assert get_method_decor(DogClient.patch) == HttpMethod.PATCH + assert get_method_decor(DogClient.delete) == HttpMethod.DELETE + assert get_method_decor(DogClient.head) == HttpMethod.HEAD + assert get_method_decor(DogClient.options) == HttpMethod.OPTIONS + assert get_method_decor(DogClient.stream_range) == HttpMethod.GET + + assert get_form_decor(DogClient.post_form) == { 'key1': 'key1', 'key2': 'keyTwo' } - assert get_decor(DogClient.queries, 'query') == { + assert get_query_decor(DogClient.queries) == { 'a': 'a', 'b': 'b', 'c': 'd' } - assert get_decor(DogClient.stream_range, 'stream') is True - assert get_decor(DogClient.stream_range, 'query') == { + assert get_stream_decor(DogClient.stream_range) is True + assert get_query_decor(DogClient.stream_range) == { 'offset': 'off', 'size': 'size' } -def test_endpoint_decorator(): +def test_endpoint_decorator() -> None: """ Tests if endpoint decorator sets the service endpoint properly. """ @@ -153,9 +163,9 @@ def test_endpoint_decorator(): assert custom_client.endpoint == 'http://dogceo.example.com' -def test_introspection(): +def test_introspection() -> None: """ - Make sure the decorators maintain the original methods + Make sure the decorators maintain the original method signatures. """ client = DogClient() diff --git a/tests/petstore_test_with_typing.py b/tests/petstore_test_with_typing.py new file mode 100644 index 0000000..333c981 --- /dev/null +++ b/tests/petstore_test_with_typing.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2018-2021 Bartosz Kryza +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys +import typing + +import time +import json +import xml.etree.ElementTree as ET +from decorest import HTTPErrorWrapper + +sys.path.append(os.path.dirname(os.path.realpath(__file__)) + "/../examples") +from swagger_petstore.petstore_client_with_typing import PetstoreClientWithTyping + + +def client(backend: typing.Literal['requests', 'httpx']) \ + -> PetstoreClientWithTyping: + # Give Docker and Swagger Petstore some time to spin up + time.sleep(2) + host = "localhost" + port = os.environ['PETSTORE_8080_TCP_PORT'] + + return PetstoreClientWithTyping( + 'http://{host}:{port}/api'.format(host=host, port=port), + backend=backend) + + +client_requests = client('requests') + +client_httpx = client('httpx') + + +def test_pet_methods(client: PetstoreClientWithTyping) -> None: + res = client.find_pet_by_status() + assert res == [] + + res = client.find_pet_by_status_xml() + assert res.tag == 'pets' + + try: + res = client.add_pet( + { + 'name': 'lucky', + 'photoUrls': ['http://example.com/lucky.jpg'], + 'status': 'available' + }, + timeout=5) + except HTTPErrorWrapper as e: + pass + + pet_id = res['id'] + res = client.find_pet_by_id(pet_id) + assert res['name'] == 'lucky' + assert res['status'] == 'available' + + try: + res = client.update_pet(json.dumps({'id': pet_id, 'status': 'sold'})) + except HTTPErrorWrapper as e: + pass + + res = client.find_pet_by_id(pet_id) + assert res['status'] == 'sold' + + try: + res = client.delete_pet(pet_id) + except HTTPErrorWrapper as e: + pass + + try: + res = client.find_pet_by_id(pet_id) + except HTTPErrorWrapper as e: + assert e.response.status_code == 404 + + assert res is None + + +def test_store_methods(client: PetstoreClientWithTyping) -> None: + + res = client.place_order({ + 'petId': 123, + 'quantity': 2, + 'shipDate': '2018-02-13T21:53:00.637Z', + 'status': 'placed', + }) + + assert res['petId'] == 123 + assert res['status'] == 'placed' + + order_id = res['id'] + res = client.get_order(order_id) + + assert res['petId'] == 123 + assert res['status'] == 'placed' + + res = client.get_inventory() + assert 'available' in res + + client.delete_order(order_id) + + +def test_user_methods(client: PetstoreClientWithTyping) -> None: + + res = client.create_user({ + 'username': 'swagger', + 'firstName': 'Swagger', + 'lastName': 'Petstore', + 'email': 'swagger@example.com', + 'password': 'guess', + 'phone': '001-111-CALL-ME', + "userStatus": 0 + }) + + assert res is True + + res = client.login('swagger', 'petstore') + + assert res.decode("utf-8").startswith('logged in user session:') + + res = client.get_user('swagger') + + assert res['phone'] == '001-111-CALL-ME' + + client.update_user( + 123, { + 'username': 'swagger', + 'firstName': 'Swagger', + 'lastName': 'Petstore', + 'email': 'swagger@example.com', + 'password': 'guess', + 'phone': '001-111-CALL-ME', + "userStatus": 0 + }) + + res = client.get_user('swagger') + + assert res['email'] == 'swagger@example.com' + assert res['password'] == 'guess' + + client.delete_user('swagger') + From 835efd66ae3258b75d9b2bdc9ef73c4bcfd917cc Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Tue, 14 Dec 2021 22:45:17 +0100 Subject: [PATCH 05/48] Added tox tests for mypy --- .gitignore | 5 +- decorest/errors.py | 4 +- decorest/types.py | 4 +- .../httpbin/httpbin_client_with_typing.py | 419 ++++++++++++++++++ .../petstore_client_with_typing.py | 29 +- mypy.ini | 3 + tox.ini | 11 + 7 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 examples/httpbin/httpbin_client_with_typing.py diff --git a/.gitignore b/.gitignore index cf31ee7..3b92b62 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,9 @@ env/ venv/ venv2/ venv3/ +venv36/ +venv37/ +venv38/ ENV/ env.bak/ venv.bak/ @@ -106,4 +109,4 @@ venv.bak/ # mypy .mypy_cache/ -.idea/ \ No newline at end of file +.idea/ diff --git a/decorest/errors.py b/decorest/errors.py index 56d8b8e..fc73c6a 100644 --- a/decorest/errors.py +++ b/decorest/errors.py @@ -41,8 +41,8 @@ def response(self) -> typing.Any: def __repr__(self) -> str: """Return wrapped representation.""" - return self.wrapped.__repr__() + return typing.cast(str, self.wrapped.__repr__()) def __str__(self) -> str: """Return wrapped str representation.""" - return self.wrapped.__str__() + return typing.cast(str, self.wrapped.__str__()) diff --git a/decorest/types.py b/decorest/types.py index a2e8e77..5c0ffc2 100644 --- a/decorest/types.py +++ b/decorest/types.py @@ -18,6 +18,8 @@ import enum import typing +import typing_extensions + DEnum = enum.Enum DIntEnum = enum.IntEnum @@ -52,7 +54,7 @@ class HttpStatus(DIntEnum): import httpx ArgsDict = typing.Dict[str, typing.Any] -Backends = typing.Literal['requests', 'httpx'] +Backends = typing_extensions.Literal['requests', 'httpx'] AuthTypes = typing.Union['requests.auth.AuthBase', 'httpx.Auth'] SessionTypes = typing.Union['requests.Session', 'httpx.Client'] HTTPErrors = typing.Union['requests.HTTPError', 'httpx.HTTPStatusError'] diff --git a/examples/httpbin/httpbin_client_with_typing.py b/examples/httpbin/httpbin_client_with_typing.py new file mode 100644 index 0000000..ba4797b --- /dev/null +++ b/examples/httpbin/httpbin_client_with_typing.py @@ -0,0 +1,419 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2018-2021 Bartosz Kryza +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Example client for HTTPBin service with typing (http://httpbin.org).""" + +import functools +import io +import json +import typing + +from PIL import Image + +from decorest import DELETE, GET, PATCH, POST, PUT +from decorest import HttpStatus, RestClient +from decorest import __version__, accept, body, content, endpoint, form +from decorest import header, multipart, on, query, stream, timeout + +JsonDictType = typing.Dict[str, typing.Any] + +F = typing.TypeVar('F', bound=typing.Callable[..., typing.Any]) + + +def repeatdecorator(f: F) -> F: + """Repeat call 5 times.""" + @functools.wraps(f) + def wrapped(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + result = [] + for i in range(5): + result.append(f(*args, **kwargs)) + return result + + return typing.cast(F, wrapped) + + +def parse_image(response: typing.Any) -> typing.Optional[Image]: + """Parse image content and create image object.""" + with io.BytesIO(response.content) as data: + with Image.open(data) as img: + return img + + return None + + +@header('user-agent', 'decorest/{v}'.format(v=__version__)) +@accept('application/json') +@endpoint('http://httpbin.org') +class HttpBinClientWithTyping(RestClient): + """Client to HttpBin service (httpbin.org).""" + + @GET('ip') + def ip(self) -> JsonDictType: + """Return Origin IP.""" + + @repeatdecorator + @GET('ip') + def ip_repeat(self) -> typing.List[JsonDictType]: + """Return Origin IP repeated n times.""" + + @GET('uuid') + def uuid(self) -> JsonDictType: + """Return UUID4.""" + + @GET('user-agent') + def user_agent(self) -> JsonDictType: + """Return user-agent.""" + + @GET('headers') + @header('B', 'BB') + def headers(self) -> JsonDictType: + """Return header dict.""" + + @GET('headers') + @header('first') + @header('second_header', 'SecondHeader') + def headers_in_args(self, first: str, second_header: str) \ + -> JsonDictType: + """Return header dict.""" + + @GET('get') + def get(self) -> JsonDictType: + """Return GET data.""" + + @POST('post') + @form('key1') + @form('key2') + @form('key3') + def post_form(self, key1: str, key2: str, key3: str) -> JsonDictType: + """Return POST form data.""" + + @POST('post') + @body('post_data') + def post(self, post_data: str) -> JsonDictType: + """Return POST data.""" + + @POST('post') + @multipart('part1') + @multipart('part_2', 'part2') + @multipart('test') + def post_multipart(self, part1: typing.Any, + part_2: typing.Any, test: typing.Any) -> JsonDictType: + """Return multipart POST data.""" + + @PATCH('patch') + @body('patch_data') + def patch(self, patch_data: str) -> JsonDictType: + """Return PATCH data.""" + + @PUT('put') + @body('put_data', lambda c: json.dumps(c, sort_keys=True, indent=4)) + def put(self, put_data: str) -> str: + """Return PUT data.""" + + @DELETE('delete') + def delete(self) -> JsonDictType: + """Return DELETE data.""" + + @POST('anything') + @body('something') + def anything(self, something: typing.Mapping[str, typing.Any]) \ + -> JsonDictType: + """Return request data, including method used.""" + + @POST('anything/{anything}') + @body('something') + def anything_anything(self, anything: str, + something: typing.Mapping[str, typing.Any]) \ + -> JsonDictType: + """Return request data, including the URL.""" + + @GET('encoding/utf8') + @accept('text/html') + def encoding_utf8(self) -> JsonDictType: + """Return request data, including the URL.""" + + @GET('gzip') + @content('application/octet-stream') + @on(200, lambda r: r.content) + def gzip(self) -> JsonDictType: + """Return gzip-encoded data.""" + + @GET('deflate') + @content('application/octet-stream') + @on(200, lambda r: r.content) + def deflate(self) -> JsonDictType: + """Return deflate-encoded data.""" + + @GET('brotli') + @content('application/octet-stream') + @on(200, lambda r: r.content) + def brotli(self) -> JsonDictType: + """Return brotli-encoded data.""" + + @GET('status/{code}') + @on(HttpStatus.ANY, lambda r: r.status_code) + def status_code(self, code: int) -> JsonDictType: + """Return given HTTP Status code.""" + + @GET('response-headers') + @query('first_name', 'firstName') + @query('last_name', 'lastName') + @query('nickname') + def response_headers(self, first_name: str, last_name: str, + nickname: str = 'httpbin') -> JsonDictType: + """Return given response headers.""" + + @GET('redirect/{n}') + def redirect(self, n: int) -> JsonDictType: + """302 Redirects n times.""" + + @GET('redirect-to') + @query('url') + def redirect_to(self, url: str) -> JsonDictType: + """302 Redirects to the foo URL.""" + + @GET('redirect-to') + @query('url') + @query('code', 'status_code') + def redirect_to_foo(self, url: str, code: int) -> JsonDictType: + """307 Redirects to the foo URL.""" + + @GET('relative-redirect/{n}') + def relative_redirect(self, n: int) -> JsonDictType: + """302 Relative redirects n times.""" + + @GET('absolute-redirect/{n}') + def absolute_redirect(self, n: int) -> JsonDictType: + """302 Absolute redirects n times.""" + + @GET('cookies') + def cookies(self) -> JsonDictType: + """Return cookie data.""" + + @GET('cookies/set') + def cookies_set(self) -> JsonDictType: + """Set one or more simple cookies.""" + + @GET('cookies/delete') + def cookies_delete(self) -> JsonDictType: + """Delete one or more simple cookies.""" + + @GET('basic-auth/{user}/{passwd}') + def basic_auth(self, user: str, passwd: str) -> JsonDictType: + """Challenge HTTPBasic Auth.""" + + @GET('hidden-basic-auth/{user}/{passwd}') + def hidden_basic_auth(self, user: str, passwd: str) -> JsonDictType: + """404'd BasicAuth.""" + + @GET('digest-auth/{qop}/{user}/{passwd}/{algorithm}/never') + def digest_auth_algorithm(self, qop: str, user: str, + passwd: str, algorithm: str) -> JsonDictType: + """Challenge HTTP Digest Auth.""" + + @GET('digest-auth/{qop}/{user}/{passwd}') + def digest_auth(self, qop: str, user: str, passwd: str) -> JsonDictType: + """Challenge HTTP Digest Auth.""" + + @GET('stream/{n}') + @stream + def stream_n(self, n: int) -> JsonDictType: + """Stream min(n, 100) lines.""" + + @GET('delay/{n}') + @timeout(2) + def delay(self, n: int) -> JsonDictType: + """Delay responding for min(n, 10) seconds.""" + + @GET('drip') + @stream + @query('numbytes') + @query('duration') + @query('delay') + @query('code') + def drip(self, numbytes: int, duration: float, + delay: int, code: int) -> JsonDictType: + """Drip data over a duration. + + Drip data over a duration after an optional initial delay, then + (optionally) Return with the given status code. + """ + + @GET('range/{n}') + @stream + @query('duration') + @query('chunk_size') + def range(self, n: int, duration: int, chunk_size: int) -> JsonDictType: + """Stream n bytes. + + Stream n bytes, and allows specifying a Range header to select + Parameterof the data. Accepts a chunk_size and request duration + parameter. + """ + + @GET('html') + @accept('text/html') + def html(self) -> JsonDictType: + """Render an HTML Page.""" + + @GET('robots.txt') + def robots_txt(self) -> JsonDictType: + """Return some robots.txt rules.""" + + @GET('deny') + def deny(self) -> JsonDictType: + """Denied by robots.txt file.""" + + @GET('cache') + def cache(self) -> JsonDictType: + """Return 200 unless an If-Modified-Since. + + Return 200 unless an If-Modified-Since or If-None-Match header + is provided, when it Return a 304. + """ + + @GET('etag/{etag}') + def etag(self, etag: str) -> JsonDictType: + """Assume the resource has the given etag. + + Assume the resource has the given etag and responds to + If-None-Match header with a 200 or 304 and If-Match with a 200 + or 412 as appropriate. + """ + + @GET('cache/{n}') + def cache_n(self, n: str) -> JsonDictType: + """Set a Cache-Control header for n seconds.""" + + @GET('bytes/{n}') + def bytes(self, n: str) -> JsonDictType: + """Generate n random bytes. + + Generate n random bytes of binary data, accepts optional seed + integer parameter. + """ + + @GET('stream-bytes/{n}') + def stream_bytes(self, n: str) -> JsonDictType: + """Stream n random bytes. + + Stream n random bytes of binary data in chunked encoding, accepts + optional seed and chunk_size integer parameters. + """ + + @GET('links/{n}') + @accept('text/html') + def links(self, n: str) -> JsonDictType: + """Return page containing n HTML links.""" + + @GET('image') + def image(self) -> JsonDictType: + """Return page containing an image based on sent Accept header.""" + + @GET('/image/png') + @accept('image/png') + @on(200, parse_image) + def image_png(self) -> typing.Optional[Image]: + """Return a PNG image.""" + + @GET('/image/jpeg') + @accept('image/jpeg') + @on(200, parse_image) + def image_jpeg(self) -> typing.Optional[Image]: + """Return a JPEG image.""" + + @GET('/image/webp') + @accept('image/webp') + @on(200, parse_image) + def image_webp(self) -> typing.Optional[Image]: + """Return a WEBP image.""" + + @GET('/image/svg') + @accept('image/svg') + def image_svg(self) -> typing.Optional[Image]: + """Return a SVG image.""" + + @POST('forms/post') + @body('forms') + def forms_post(self, forms: typing.Mapping[str, typing.Any]) \ + -> JsonDictType: + """HTML form that submits to /post.""" + + @GET('xml') + @accept('application/xml') + def xml(self) -> str: + """Return some XML.""" + + +# +# These checks only validate that typing works, they are not meant +# to be executed +# +client = HttpBinClientWithTyping('http://example.com', backend='requests') + +assert client.ip() == {'ip': '1.2.3.4'} +assert client.ip_repeat() \ + == [{'ip': '1.2.3.4'}, {'ip': '1.2.3.4'}, {'ip': '1.2.3.4'}] +assert client.uuid() == {'ip': '1.2.3.4'} +assert client.user_agent() == {'ip': '1.2.3.4'} +assert client.headers() == {'ip': '1.2.3.4'} +assert client.headers_in_args('a', 'b') == {'ip': '1.2.3.4'} +assert client.get() == {'ip': '1.2.3.4'} +assert client.post_form('a', 'b', 'c') == {'ip': '1.2.3.4'} +assert client.post('a') == {'ip': '1.2.3.4'} +assert client.post_multipart('a', 'b', {'c', 'd'}) == {'ip': '1.2.3.4'} +assert client.patch('a') == {'ip': '1.2.3.4'} +assert client.put('a') == '"ip": "1.2.3.4"' +assert client.delete() == {'ip': '1.2.3.4'} +assert client.anything({'a': 123}) == {'ip': '1.2.3.4'} +assert client.anything_anything('a', {'b': 123}) == {'ip': '1.2.3.4'} +assert client.encoding_utf8() == {'ip': '1.2.3.4'} +assert client.gzip() == {'ip': '1.2.3.4'} +assert client.deflate() == {'ip': '1.2.3.4'} +assert client.brotli() == {'ip': '1.2.3.4'} +assert client.status_code(100) == {'ip': '1.2.3.4'} +assert client.response_headers('a', 'b') == {'ip': '1.2.3.4'} +assert client.redirect(5) == {'ip': '1.2.3.4'} +assert client.redirect_to('example.com') == {'ip': '1.2.3.4'} +assert client.redirect_to_foo('a', 301) == {'ip': '1.2.3.4'} +assert client.relative_redirect(2) == {'ip': '1.2.3.4'} +assert client.absolute_redirect(1) == {'ip': '1.2.3.4'} +assert client.cookies() == {'ip': '1.2.3.4'} +assert client.cookies_set() == {'ip': '1.2.3.4'} +assert client.cookies_delete() == {'ip': '1.2.3.4'} +assert client.basic_auth('a', 'b') == {'ip': '1.2.3.4'} +assert client.hidden_basic_auth('a', 'b') == {'ip': '1.2.3.4'} +assert client.digest_auth_algorithm('a', 'b', 'c', 'd') == {'ip': '1.2.3.4'} +assert client.digest_auth('a', 'b', 'c') == {'ip': '1.2.3.4'} +assert client.stream_n(2) == {'ip': '1.2.3.4'} +assert client.delay(1) == {'ip': '1.2.3.4'} +assert client.drip(1, 2.2, 2, 3) == {'ip': '1.2.3.4'} +assert client.range(1, 2, 3) == {'ip': '1.2.3.4'} +assert client.html() == {'ip': '1.2.3.4'} +assert client.robots_txt() == {'ip': '1.2.3.4'} +assert client.deny() == {'ip': '1.2.3.4'} +assert client.cache() == {'ip': '1.2.3.4'} +assert client.etag('a') == {'ip': '1.2.3.4'} +assert client.cache_n('a') == {'ip': '1.2.3.4'} +assert client.bytes('a') == {'ip': '1.2.3.4'} +assert client.stream_bytes('a') == {'ip': '1.2.3.4'} +assert client.links('a') == {'ip': '1.2.3.4'} +assert client.image() == {'ip': '1.2.3.4'} +assert client.image_png() == Image.Image() +assert client.image_jpeg() == Image.Image() +assert client.image_webp() == Image.Image() +assert client.image_svg() == Image.Image() +assert client.forms_post({'a': 123}) == {'ip': '1.2.3.4'} +assert client.xml() == 'b' diff --git a/examples/swagger_petstore/petstore_client_with_typing.py b/examples/swagger_petstore/petstore_client_with_typing.py index 4451a19..050ea9f 100644 --- a/examples/swagger_petstore/petstore_client_with_typing.py +++ b/examples/swagger_petstore/petstore_client_with_typing.py @@ -14,8 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """ -Example decorest based client to Swagger Petstore sample service, -including typing hints. +Example decorest based client to Swagger Petstore sample service. http://petstore.swagger.io/ @@ -25,6 +24,8 @@ import typing import xml.etree.ElementTree as ET +from PIL.Image import Image + from decorest import DELETE, GET, POST, PUT from decorest import HttpStatus, RestClient from decorest import __version__ @@ -79,7 +80,7 @@ def delete_pet(self, pet_id: str) -> None: @POST('pet/{pet_id}/uploadImage') @body('pet', lambda p: json.dumps(p)) - def upload_pet_image(self, pet_id: str, image: typing.Any) -> None: + def upload_pet_image(self, pet_id: str, image: Image) -> None: """Upload an image.""" @@ -110,17 +111,19 @@ class UserAPI(RestClient): @POST('user') @body('user', lambda o: json.dumps(o)) @on(200, lambda r: True) - def create_user(self, user: JsonDictType) -> None: + def create_user(self, user: JsonDictType) -> bool: """Create user.""" @POST('user/createWithArray') @body('user', lambda o: json.dumps(o)) - def create_users_from_array(self, user: JsonDictType) -> None: + def create_users_from_array(self, user: typing.List[JsonDictType]) \ + -> None: """Create list of users with given input array.""" @POST('user/createWithList') @body('user', lambda o: json.dumps(o)) - def create_users_from_list(self, user: JsonDictType) -> None: + def create_users_from_list(self, user: typing.List[JsonDictType]) \ + -> None: """Create list of users with given input array.""" @GET('user/login') @@ -165,4 +168,16 @@ class PetstoreClientWithTyping(PetAPI, StoreAPI, UserAPI): assert client.find_pet_by_id('123') == {'a': {'b': 'c'}} assert client.update_pet_by_id('123', {'a': {'b': 'c'}}) is None assert client.delete_pet('123') is None -assert client.upload_pet_image('123', ) is None \ No newline at end of file +assert client.upload_pet_image('123', Image()) is None +assert client.get_inventory() == {'a': {'b': 'c'}} +assert client.place_order({'a': {'b': 'c'}}) is None +assert client.get_order('a') == {'a': {'b': 'c'}} +assert client.delete_order('a') is None +assert client.create_user({'a': {'b': 'c'}}) is True +assert client.create_users_from_array([{'a': {'b': 'c'}}]) is None +assert client.create_users_from_list([{'a': {'b': 'c'}}]) is None +assert client.login('a', 'b') == {'a': {'b': 'c'}} +assert client.logout() == {'a': {'b': 'c'}} +assert client.get_user('a') == {'a': {'b': 'c'}} +assert client.update_user('a', {'a': {'b': 'c'}}) == {'a': {'b': 'c'}} +assert client.delete_user('a') is None diff --git a/mypy.ini b/mypy.ini index b6b414c..6c363c7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -12,4 +12,7 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-pytest.*] +ignore_missing_imports = True + +[mypy-PIL.*] ignore_missing_imports = True \ No newline at end of file diff --git a/tox.ini b/tox.ini index 435f384..63be706 100644 --- a/tox.ini +++ b/tox.ini @@ -20,6 +20,17 @@ deps = commands = flake8 decorest examples setup.py +[testenv:mypy] +basepython = python3 +skip_install = true +deps = + mypy +commands = + python -m mypy --strict --disallow-untyped-defs --show-error-context --install-types \ + decorest \ + examples/httpbin/httpbin_client_with_typing.py \ + examples/swagger_petstore/petstore_client_with_typing.py + [testenv:basic] commands = py.test -v --cov=decorest [] tests/decorators_tests.py From 66cb646ceaf13ad4dea5580aacbdc1a0adfd897a Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Tue, 14 Dec 2021 22:53:10 +0100 Subject: [PATCH 06/48] Fixed tox tests --- decorest/types.py | 1 - requirements.txt | 2 ++ tox.ini | 8 ++++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/decorest/types.py b/decorest/types.py index 5c0ffc2..1ba96a8 100644 --- a/decorest/types.py +++ b/decorest/types.py @@ -59,7 +59,6 @@ class HttpStatus(DIntEnum): SessionTypes = typing.Union['requests.Session', 'httpx.Client'] HTTPErrors = typing.Union['requests.HTTPError', 'httpx.HTTPStatusError'] -# _TDecor = typing.Callable[..., typing.Any] TDecor = typing.TypeVar('TDecor', bound=typing.Callable[..., typing.Any]) diff --git a/requirements.txt b/requirements.txt index f229360..cafa70f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ requests +typing +typing-extensions diff --git a/tox.ini b/tox.ini index 63be706..48be408 100644 --- a/tox.ini +++ b/tox.ini @@ -32,6 +32,12 @@ commands = examples/swagger_petstore/petstore_client_with_typing.py [testenv:basic] +deps = + pytest + pytest-cov + requests + requests-toolbelt + typing-extensions commands = py.test -v --cov=decorest [] tests/decorators_tests.py [testenv:swaggerpetstore] @@ -42,6 +48,7 @@ deps = pytest-cov requests requests-toolbelt + typing-extensions httpx brotli commands = py.test -v --cov=decorest [] tests/petstore_test.py @@ -54,6 +61,7 @@ deps = pytest-cov requests requests-toolbelt + typing-extensions httpx Pillow brotli From 8b8c3175dd7fdf8d9f01724a651d66341b480098 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Tue, 14 Dec 2021 22:54:53 +0100 Subject: [PATCH 07/48] Updated changelog --- CHANGELOG.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 14056de..3433dc1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,7 @@ ++++++++++++++++++ * Deprecated Python 2 support +* Added typing support and mypy tests 0.0.7 (2021-11-27) ++++++++++++++++++ From 9b0bd93bce03b5a089fad70c636a78874147053e Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Tue, 14 Dec 2021 22:56:16 +0100 Subject: [PATCH 08/48] Updated github actions to run mypy validation --- .github/workflows/workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index ea12fdd..24addc5 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -28,4 +28,4 @@ jobs: python-version: ${{ matrix.python-version }} - run: pip install tox tox-gh-actions tox-docker==$TOX_DOCKER_VERSION - - run: tox -c tox.ini -e flake8,basic,swaggerpetstore,httpbin \ No newline at end of file + - run: tox -c tox.ini -e flake8,mypy,basic,swaggerpetstore,httpbin \ No newline at end of file From e8798ab14b431eb7541934aa9dd840c5598554bd Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Thu, 23 Dec 2021 10:44:31 +0100 Subject: [PATCH 09/48] Added initial asyncio support --- decorest/DELETE.py | 11 + decorest/GET.py | 9 + decorest/HEAD.py | 8 + decorest/OPTIONS.py | 9 + decorest/PATCH.py | 9 + decorest/POST.py | 9 + decorest/PUT.py | 9 + decorest/client.py | 57 +- decorest/decorators.py | 389 +++++++------ examples/httpbin/httpbin_async_client.py | 343 ++++++++++++ tests/httpbin_async_test.py | 677 +++++++++++++++++++++++ tox.ini | 21 +- 12 files changed, 1389 insertions(+), 162 deletions(-) create mode 100644 examples/httpbin/httpbin_async_client.py create mode 100644 tests/httpbin_async_test.py diff --git a/decorest/DELETE.py b/decorest/DELETE.py index b0c6cdf..59c9c6a 100644 --- a/decorest/DELETE.py +++ b/decorest/DELETE.py @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """DELETE Http method decorator.""" +import asyncio import typing from functools import wraps @@ -31,6 +32,16 @@ def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.DELETE) + if asyncio.iscoroutinefunction(func): + @wraps(func) + async def delete_decorator(*args: typing.Any, + **kwargs: typing.Any) \ + -> typing.Any: + return await super(DELETE, self).call_async(func, + *args, **kwargs) + + return typing.cast(TDecor, delete_decorator) + @wraps(func) def delete_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: diff --git a/decorest/GET.py b/decorest/GET.py index 1a13daf..d484cf3 100644 --- a/decorest/GET.py +++ b/decorest/GET.py @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """GET Http method decorator.""" +import asyncio import typing from functools import wraps @@ -31,6 +32,14 @@ def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.GET) + if asyncio.iscoroutinefunction(func): + @wraps(func) + async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: + return await super(GET, self).call_async(func, *args, **kwargs) + + return typing.cast(TDecor, get_decorator) + @wraps(func) def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: diff --git a/decorest/HEAD.py b/decorest/HEAD.py index 595055f..10ed1f0 100644 --- a/decorest/HEAD.py +++ b/decorest/HEAD.py @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """HEAD Http method decorator.""" +import asyncio import typing from functools import wraps @@ -31,6 +32,13 @@ def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.HEAD) + if asyncio.iscoroutinefunction(func): + @wraps(func) + async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: + return await super(HEAD, self).call_async(func, *args, **kwargs) + + return typing.cast(TDecor, get_decorator) @wraps(func) def head_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: diff --git a/decorest/OPTIONS.py b/decorest/OPTIONS.py index 35777d7..b59f0c2 100644 --- a/decorest/OPTIONS.py +++ b/decorest/OPTIONS.py @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """OPTIONS Http method decorator.""" +import asyncio import typing from functools import wraps @@ -31,6 +32,14 @@ def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.OPTIONS) + if asyncio.iscoroutinefunction(func): + @wraps(func) + async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: + return await super(OPTIONS, self).call_async(func, *args, **kwargs) + + return typing.cast(TDecor, get_decorator) + @wraps(func) def options_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: diff --git a/decorest/PATCH.py b/decorest/PATCH.py index 9606cd1..22e878e 100644 --- a/decorest/PATCH.py +++ b/decorest/PATCH.py @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """PATCH Http method decorator.""" +import asyncio import typing from functools import wraps @@ -31,6 +32,14 @@ def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.PATCH) + if asyncio.iscoroutinefunction(func): + @wraps(func) + async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: + return await super(PATCH, self).call_async(func, *args, **kwargs) + + return typing.cast(TDecor, get_decorator) + @wraps(func) def patch_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: diff --git a/decorest/POST.py b/decorest/POST.py index 37d3776..3a9812e 100644 --- a/decorest/POST.py +++ b/decorest/POST.py @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """POST Http method decorator.""" +import asyncio import typing from functools import wraps @@ -31,6 +32,14 @@ def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.POST) + if asyncio.iscoroutinefunction(func): + @wraps(func) + async def post_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: + return await super(POST, self).call_async(func, *args, **kwargs) + + return typing.cast(TDecor, post_decorator) + @wraps(func) def post_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: diff --git a/decorest/PUT.py b/decorest/PUT.py index c33d7e1..7c80749 100644 --- a/decorest/PUT.py +++ b/decorest/PUT.py @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """PUT Http method decorator.""" +import asyncio import typing from functools import wraps @@ -31,6 +32,14 @@ def __call__(self, func: TDecor) -> TDecor: """Callable operator.""" set_decor(func, 'http_method', HttpMethod.PUT) + if asyncio.iscoroutinefunction(func): + @wraps(func) + async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ + -> typing.Any: + return await super(PUT, self).call_async(func, *args, **kwargs) + + return typing.cast(TDecor, get_decorator) + @wraps(func) def put_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: diff --git a/decorest/client.py b/decorest/client.py index 14abc99..cb6f643 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -18,6 +18,7 @@ This module contains also some enums for HTTP protocol. """ +import asyncio import logging as LOG import typing import urllib.parse @@ -70,6 +71,47 @@ def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: return invoker +class RestClientAsyncSession: + """Wrap a `requests` session for specific API client.""" + def __init__(self, client: 'RestClient') -> None: + """Initialize the session instance with a specific API client.""" + self.__client: 'RestClient' = client + + # Create a session of type specific for given backend + import httpx + self.__session = httpx.AsyncClient() + + if self.__client.auth is not None: + self.__session.auth = self.__client.auth + + async def __aenter__(self) -> 'RestClientAsyncSession': + """Context manager initialization.""" + await self.__session.__aenter__() + return self + + async def __aexit__(self, *args: typing.Any) -> None: + """Context manager destruction.""" + await self.__session.__aexit__(*args) + + def __getattr__(self, name: str) -> typing.Any: + """Forward any method invocation to actual client with session.""" + if name == '_requests_session': + return self.__session + + if name == '_client': + return self.__client + + if name == '_close': + return self.__session.aclose + + async def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + kwargs['__session'] = self.__session + assert asyncio.iscoroutinefunction(getattr(self.__client, name)) + return await getattr(self.__client, name)(*args, **kwargs) + + return invoker + + class RestClient: """Base class for decorest REST clients.""" def __init__(self, @@ -87,13 +129,26 @@ def _session(self) -> RestClientSession: """ Initialize RestClientSession session object. - The `decorest` session object wraps a `requests` session object. + The `decorest` session object wraps a `requests` or `httpx` + session object. Each valid API method defined in the API client can be called directly via the session object. """ return RestClientSession(self) + def _async_session(self) -> RestClientAsyncSession: + """ + Initialize RestClientAsyncSession session object. + + The `decorest` session object wraps a `requests` or `httpx` + session object. + + Each valid API method defined in the API client can be called + directly via the session object. + """ + return RestClientAsyncSession(self) + def _set_auth(self, auth: AuthTypes) -> None: """ Set a default authentication method for the client. diff --git a/decorest/decorators.py b/decorest/decorators.py index 206cb9c..a255a19 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -24,6 +24,7 @@ import json import logging as LOG import numbers +import pprint import typing from operator import methodcaller @@ -317,25 +318,39 @@ def stream(t: TDecor) -> TDecor: return t -class HttpMethodDecorator: - """Abstract decorator for HTTP method decorators.""" - - def __init__(self, path: str): - """Initialize decorator with endpoint relative path.""" - self.path_template = path +# return execution_context, on_handlers, rest_client +class HttpRequest: + """ + Class representing an HTTP request created based on the decorators and arguments. + """ + http_method: str + is_multipart_request: bool + is_stream: bool + req: str + kwargs: ArgsDict + on_handlers: typing.Mapping[int, typing.Callable[..., typing.Any]] + session: str + execution_context: typing.Any + rest_client: 'RestClient' + + def __init__(self, func, path_template, args, kwargs): + self.http_method = get_method_decor(func) + self.path_template = path_template + self.kwargs = kwargs + + if self.http_method not in (HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, + HttpMethod.PATCH, HttpMethod.DELETE, + HttpMethod.HEAD, HttpMethod.OPTIONS): + raise ValueError( + 'Unsupported HTTP method: {method}'.format(method=self.http_method)) - def call(self, func: typing.Callable[..., typing.Any], - *args: typing.Any, **kwargs: typing.Any) -> typing.Any: - """Execute the API HTTP request.""" - http_method = get_method_decor(func) - rest_client = args[0] + self.rest_client = args[0] args_dict = dict_from_args(func, *args) req_path = render_path(self.path_template, args_dict) - session = None - if '__session' in kwargs: - session = kwargs['__session'] - del kwargs['__session'] - + self.session = None + if '__session' in self.kwargs: + self.session = self.kwargs['__session'] + del self.kwargs['__session'] # Merge query parameters from common values for all method # invocations with arguments provided in the method # arguments @@ -343,9 +358,8 @@ def call(self, func: typing.Callable[..., typing.Any], form_parameters = self.__merge_args(args_dict, func, 'form') multipart_parameters = self.__merge_args(args_dict, func, 'multipart') header_parameters = merge_dicts( - get_header_decor(rest_client.__class__), + get_header_decor(self.rest_client.__class__), self.__merge_args(args_dict, func, 'header')) - # Merge header parameters with default values, treat header # decorators with 2 params as default values only if they # don't match the function argument names @@ -354,7 +368,6 @@ def call(self, func: typing.Callable[..., typing.Any], for key in func_header_decors.keys(): if not func_header_decors[key] in args_dict: header_parameters[key] = func_header_decors[key] - # Get body content from positional arguments if one is specified # using @body decorator body_parameter = get_body_decor(func) @@ -365,186 +378,187 @@ def call(self, func: typing.Callable[..., typing.Any], # was provided if body_content and body_parameter[1]: body_content = body_parameter[1](body_content) - # Get authentication method for this call - auth = rest_client._auth() - + auth = self.rest_client._auth() # Get status handlers - on_handlers = merge_dicts(get_on_decor(rest_client.__class__), + self.on_handlers = merge_dicts(get_on_decor(self.rest_client.__class__), get_on_decor(func)) - # Get timeout - request_timeout = get_timeout_decor(rest_client.__class__) + request_timeout = get_timeout_decor(self.rest_client.__class__) if get_timeout_decor(func): request_timeout = get_timeout_decor(func) - # Check if stream is requested for this call - is_stream = get_stream_decor(func) - if is_stream is None: - is_stream = get_stream_decor(rest_client.__class__) - + self.is_stream = get_stream_decor(func) + if self.is_stream is None: + self.is_stream = get_stream_decor(self.rest_client.__class__) # # If the kwargs contains any decorest decorators that should # be overloaded for this call, extract them. # # Pass the rest of kwargs to requests calls # - if kwargs: + if self.kwargs: for decor in DECOR_LIST: - if decor in kwargs: + if decor in self.kwargs: if decor == 'header': - self.__validate_decor(decor, kwargs, dict) + self.__validate_decor(decor, self.kwargs, dict) header_parameters = merge_dicts( - header_parameters, kwargs['header']) - del kwargs['header'] + header_parameters, self.kwargs['header']) + del self.kwargs['header'] elif decor == 'query': - self.__validate_decor(decor, kwargs, dict) + self.__validate_decor(decor, self.kwargs, dict) query_parameters = merge_dicts(query_parameters, - kwargs['query']) - del kwargs['query'] + self.kwargs['query']) + del self.kwargs['query'] elif decor == 'form': - self.__validate_decor(decor, kwargs, dict) + self.__validate_decor(decor, self.kwargs, dict) form_parameters = merge_dicts(form_parameters, - kwargs['form']) - del kwargs['form'] + self.kwargs['form']) + del self.kwargs['form'] elif decor == 'multipart': - self.__validate_decor(decor, kwargs, dict) + self.__validate_decor(decor, self.kwargs, dict) multipart_parameters = merge_dicts( - multipart_parameters, kwargs['multipart']) - del kwargs['multipart'] + multipart_parameters, self.kwargs['multipart']) + del self.kwargs['multipart'] elif decor == 'on': - self.__validate_decor(decor, kwargs, dict) - on_handlers = merge_dicts(on_handlers, kwargs['on']) - del kwargs['on'] + self.__validate_decor(decor, self.kwargs, dict) + self.on_handlers = merge_dicts(self.on_handlers, self.kwargs['on']) + del self.kwargs['on'] elif decor == 'accept': - self.__validate_decor(decor, kwargs, str) - header_parameters['accept'] = kwargs['accept'] - del kwargs['accept'] + self.__validate_decor(decor, self.kwargs, str) + header_parameters['accept'] = self.kwargs['accept'] + del self.kwargs['accept'] elif decor == 'content': - self.__validate_decor(decor, kwargs, str) - header_parameters['content-type'] = kwargs['content'] - del kwargs['content'] + self.__validate_decor(decor, self.kwargs, str) + header_parameters['content-type'] = self.kwargs['content'] + del self.kwargs['content'] elif decor == 'timeout': - self.__validate_decor(decor, kwargs, numbers.Number) - request_timeout = kwargs['timeout'] - del kwargs['timeout'] + self.__validate_decor(decor, self.kwargs, numbers.Number) + request_timeout = self.kwargs['timeout'] + del self.kwargs['timeout'] elif decor == 'stream': - self.__validate_decor(decor, kwargs, bool) - is_stream = kwargs['stream'] - del kwargs['stream'] + self.__validate_decor(decor, self.kwargs, bool) + self.is_stream = self.kwargs['stream'] + del self.kwargs['stream'] elif decor == 'body': - body_content = kwargs['body'] - del kwargs['body'] + body_content = self.kwargs['body'] + del self.kwargs['body'] else: pass - # Build request from endpoint and query params - req = rest_client.build_request(req_path.split('/')) + self.req = self.rest_client.build_request(req_path.split('/')) # Handle multipart parameters, either from decorators # or ones passed directly through kwargs if multipart_parameters: - is_multipart_request = True - kwargs['files'] = multipart_parameters - elif rest_client._backend() == 'requests': + self.is_multipart_request = True + self.kwargs['files'] = multipart_parameters + elif self.rest_client._backend() == 'requests': from requests_toolbelt.multipart.encoder import MultipartEncoder - is_multipart_request = 'data' in kwargs and not isinstance( - kwargs['data'], MultipartEncoder) + self.is_multipart_request = 'data' in self.kwargs and not isinstance( + self.kwargs['data'], MultipartEncoder) else: - is_multipart_request = 'files' in kwargs + self.is_multipart_request = 'files' in self.kwargs # Assume default content type if not multipart if ('content-type' not in header_parameters) \ - and not is_multipart_request: + and not self.is_multipart_request: header_parameters['content-type'] = 'application/json' # Assume default accept if 'accept' not in header_parameters: header_parameters['accept'] = 'application/json' - LOG.debug('Request: {method} {request}'.format(method=http_method, - request=req)) - + LOG.debug('Request: {method} {request}'.format(method=self.http_method, + request=self.req)) if auth: - kwargs['auth'] = auth - + self.kwargs['auth'] = auth if request_timeout: - kwargs['timeout'] = request_timeout - + self.kwargs['timeout'] = request_timeout if body_content: if header_parameters.get('content-type') == 'application/json': if isinstance(body_content, dict): body_content = json.dumps(body_content) - if rest_client._backend() == 'httpx': + if self.rest_client._backend() == 'httpx': if isinstance(body_content, dict): - kwargs['data'] = body_content + self.kwargs['data'] = body_content else: - kwargs['content'] = body_content + self.kwargs['content'] = body_content else: kwargs['data'] = body_content - if query_parameters: - kwargs['params'] = query_parameters - + self.kwargs['params'] = query_parameters if form_parameters: # If form parameters were passed, override the content-type header_parameters['content-type'] \ = 'application/x-www-form-urlencoded' - kwargs['data'] = form_parameters - - if is_stream: - kwargs['stream'] = is_stream - + self.kwargs['data'] = form_parameters + if self.is_stream: + self.kwargs['stream'] = self.is_stream if header_parameters: - kwargs['headers'] = dict(header_parameters.items()) - - result = None + self.kwargs['headers'] = dict(header_parameters.items()) # If '__session' was passed in the kwargs, execute this request # using the session context, otherwise execute directly via the # requests module - if session: - execution_context = session + if self.session: + self.execution_context = self.session else: - if rest_client._backend() == 'requests': - execution_context = requests + if self.rest_client._backend() == 'requests': + self.execution_context = requests else: import httpx - execution_context = httpx + self.execution_context = httpx - if http_method not in (HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, - HttpMethod.PATCH, HttpMethod.DELETE, - HttpMethod.HEAD, HttpMethod.OPTIONS): - raise ValueError( - 'Unsupported HTTP method: {method}'.format(method=http_method)) + def __validate_decor(self, decor: str, kwargs: ArgsDict, + cls: typing.Type[typing.Any]) -> None: + """ + Ensure kwargs contain decor with specific type. - try: - if rest_client._backend() == 'httpx' \ - and http_method == HttpMethod.GET and is_stream: - del kwargs['stream'] - result = execution_context.stream("GET", req, **kwargs) - else: - if http_method == HttpMethod.POST and is_multipart_request: - # TODO: Why do I have to do this? - if 'headers' in kwargs: - kwargs['headers'].pop('content-type', None) + Args: + decor(str): Name of the decorator + kwargs(dict): Named arguments passed to API call + cls(class): Expected type of decorator parameter + """ + if not isinstance(kwargs[decor], cls): + raise TypeError( + "{} value must be an instance of {}".format( + decor, cls.__name__)) - result = self.__dispatch( - execution_context, http_method, kwargs, req) - except Exception as e: - raise HTTPErrorWrapper(typing.cast(types.HTTPErrors, e)) + def __merge_args(self, args_dict: ArgsDict, + func: typing.Callable[..., typing.Any], decor: str) \ + -> ArgsDict: + """ + Match named arguments from method call. + + Args: + args_dict (dict): Function arguments dictionary + func (type): Decorated function + decor (str): Name of specific decorator (e.g. 'query') + + Returns: + object: any value assigned to the name key + """ + args_decor = get_decor(func, decor) + parameters = {} + if args_decor: + for arg, param in args_decor.items(): + if args_dict.get(arg): + parameters[param] = args_dict[arg] + return parameters - if on_handlers and result.status_code in on_handlers: + def handle(self, result): + if self.on_handlers and result.status_code in self.on_handlers: # Use a registered handler for the returned status code - return on_handlers[result.status_code](result) - elif on_handlers and HttpStatus.ANY in on_handlers: + return self.on_handlers[result.status_code](result) + elif self.on_handlers and HttpStatus.ANY in self.on_handlers: # If a catch all status handler is provided - use it - return on_handlers[HttpStatus.ANY](result) + return self.on_handlers[HttpStatus.ANY](result) else: # If stream option was passed and no content handler - # was defined, return requests response - if is_stream: + # was defined, return response + if self.is_stream: return result # Default response handler @@ -564,9 +578,65 @@ def call(self, func: typing.Callable[..., typing.Any], return None - def __dispatch(self, execution_context: typing.Callable[..., typing.Any], - http_method: typing.Union[str, HttpMethod], - kwargs: ArgsDict, req: str) -> typing.Any: + +class HttpMethodDecorator: + """Abstract decorator for HTTP method decorators.""" + + def __init__(self, path: str): + """Initialize decorator with endpoint relative path.""" + self.path_template = path + + async def call_async(self, func: typing.Callable[..., typing.Any], + *args: typing.Any, **kwargs: typing.Any) -> typing.Any: + + http_request = HttpRequest(func, self.path_template, args, kwargs) + + try: + if http_request.http_method == HttpMethod.GET \ + and http_request.is_stream: + del kwargs['stream'] + result \ + = await http_request.execution_context.stream("GET", http_request.req, **http_request.kwargs) + else: + if http_request.http_method == HttpMethod.POST \ + and http_request.is_multipart_request: + # TODO: Why do I have to do this? + if 'headers' in http_request.kwargs: + http_request.kwargs['headers'].pop('content-type', None) + + result = await self.__dispatch_async(http_request) + except Exception as e: + raise HTTPErrorWrapper(typing.cast(types.HTTPErrors, e)) + + return http_request.handle(result) + + def call(self, func: typing.Callable[..., typing.Any], + *args: typing.Any, **kwargs: typing.Any) -> typing.Any: + """Execute the API HTTP request.""" + + http_request = HttpRequest(func, self.path_template, args, kwargs) + + try: + if http_request.rest_client._backend() == 'httpx' \ + and http_request.http_method == HttpMethod.GET \ + and http_request.is_stream: + del kwargs['stream'] + result \ + = http_request.execution_context.stream("GET", http_request.req, **http_request.kwargs) + else: + if http_request.http_method == HttpMethod.POST \ + and http_request.is_multipart_request: + # TODO: Why do I have to do this? + if 'headers' in http_request.kwargs: + http_request.kwargs['headers'].pop('content-type', None) + + result = self.__dispatch(http_request) + except Exception as e: + raise HTTPErrorWrapper(typing.cast(types.HTTPErrors, e)) + + return http_request.handle(result) + + def __dispatch(self, http_request: HttpRequest) -> typing.Any: """ Dispatch HTTP method based on HTTPMethod enum type. @@ -576,46 +646,45 @@ def __dispatch(self, execution_context: typing.Callable[..., typing.Any], kwargs(dict): named arguments passed to the API method req(): request object """ - if isinstance(http_method, str): - method = http_method + if isinstance(http_request.http_method, str): + method = http_request.http_method else: - method = http_method.value[0].lower() + method = http_request.http_method.value[0].lower() - return methodcaller(method, req, **kwargs)(execution_context) + ctx = http_request.execution_context + return methodcaller(method, + http_request.req, + **http_request.kwargs)(ctx) - def __validate_decor(self, decor: str, kwargs: ArgsDict, - cls: typing.Type[typing.Any]) -> None: + async def __dispatch_async(self, http_request: HttpRequest) -> typing.Any: """ - Ensure kwargs contain decor with specific type. - - Args: - decor(str): Name of the decorator - kwargs(dict): Named arguments passed to API call - cls(class): Expected type of decorator parameter - """ - if not isinstance(kwargs[decor], cls): - raise TypeError( - "{} value must be an instance of {}".format( - decor, cls.__name__)) - - def __merge_args(self, args_dict: ArgsDict, - func: typing.Callable[..., typing.Any], decor: str) \ - -> ArgsDict: - """ - Match named arguments from method call. + Dispatch HTTP method based on HTTPMethod enum type. Args: - args_dict (dict): Function arguments dictionary - func (type): Decorated function - decor (str): Name of specific decorator (e.g. 'query') - - Returns: - object: any value assigned to the name key + execution_context: requests or httpx object + http_method(HttpMethod): HTTP method + kwargs(dict): named arguments passed to the API method + req(): request object """ - args_decor = get_decor(func, decor) - parameters = {} - if args_decor: - for arg, param in args_decor.items(): - if args_dict.get(arg): - parameters[param] = args_dict[arg] - return parameters + if isinstance(http_request.http_method, str): + method = http_request.http_method + else: + method = http_request.http_method.value[0].lower() + + import httpx + + async with httpx.AsyncClient() as client: + if method == 'get': + response = await client.get(http_request.req, **http_request.kwargs) + elif method == 'post': + response = await client.post(http_request.req, **http_request.kwargs) + elif method == 'put': + response = await client.put(http_request.req, **http_request.kwargs) + elif method == 'patch': + response = await client.patch(http_request.req, **http_request.kwargs) + elif method == 'head': + response = await client.head(http_request.req, **http_request.kwargs) + elif method == 'delete': + response = await client.delete(http_request.req, **http_request.kwargs) + + return response diff --git a/examples/httpbin/httpbin_async_client.py b/examples/httpbin/httpbin_async_client.py new file mode 100644 index 0000000..b5db6d6 --- /dev/null +++ b/examples/httpbin/httpbin_async_client.py @@ -0,0 +1,343 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2018-2021 Bartosz Kryza +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Example client for HTTPBin service (http://httpbin.org).""" + +import functools +import io +import json + +from PIL import Image + +from decorest import DELETE, GET, PATCH, POST, PUT +from decorest import HttpStatus, RestClient +from decorest import __version__, accept, body, content, endpoint, form +from decorest import header, multipart, on, query, stream, timeout + + +def repeatdecorator(f): + """Repeat call 5 times.""" + @functools.wraps(f) + async def wrapped(*args, **kwargs): + result = [] + for i in range(5): + result.append(await f(*args, **kwargs)) + return result + + return wrapped + + +def parse_image(response): + """Parse image content and create image object.""" + with io.BytesIO(response.content) as data: + with Image.open(data) as img: + return img + + return None + + +@header('user-agent', 'decorest/{v}'.format(v=__version__)) +@accept('application/json') +@endpoint('http://httpbin.org') +class HttpBinAsyncClient(RestClient): + """Client to HttpBin service (httpbin.org).""" + + @GET('ip') + async def ip(self): + """Return Origin IP.""" + + @repeatdecorator + @GET('ip') + async def ip_repeat(self): + """Return Origin IP repeated n times.""" + + @GET('uuid') + async def uuid(self): + """Return UUID4.""" + + @GET('user-agent') + async def user_agent(self): + """Return user-agent.""" + + @GET('headers') + @header('B', 'BB') + async def headers(self): + """Return header dict.""" + + @GET('headers') + @header('first') + @header('second_header', 'SecondHeader') + async def headers_in_args(self, first, second_header): + """Return header dict.""" + + @GET('get') + async def get(self): + """Return GET data.""" + + @POST('post') + @form('key1') + @form('key2') + @form('key3') + async def post_form(self, key1, key2, key3): + """Return POST form data.""" + + @POST('post') + @body('post_data') + async def post(self, post_data): + """Return POST data.""" + + @POST('post') + @multipart('part1') + @multipart('part_2', 'part2') + @multipart('test') + async def post_multipart(self, part1, part_2, test): + """Return multipart POST data.""" + + @PATCH('patch') + @body('patch_data') + async def patch(self, patch_data): + """Return PATCH data.""" + + @PUT('put') + @body('put_data', lambda c: json.dumps(c, sort_keys=True, indent=4)) + async def put(self, put_data): + """Return PUT data.""" + + @DELETE('delete') + async def delete(self): + """Return DELETE data.""" + + @POST('anything') + @body('content') + async def anything(self, content): + """Return request data, including method used.""" + + @POST('anything/{anything}') + @body('content') + async def anything_anything(self, anything, content): + """Return request data, including the URL.""" + + @GET('encoding/utf8') + @accept('text/html') + async def encoding_utf8(self): + """Return request data, including the URL.""" + + @GET('gzip') + @content('application/octet-stream') + @on(200, lambda r: r.content) + async def gzip(self): + """Return gzip-encoded data.""" + + @GET('deflate') + @content('application/octet-stream') + @on(200, lambda r: r.content) + async def deflate(self): + """Return deflate-encoded data.""" + + @GET('brotli') + @content('application/octet-stream') + @on(200, lambda r: r.content) + async def brotli(self): + """Return brotli-encoded data.""" + + @GET('status/{code}') + @on(HttpStatus.ANY, lambda r: r.status_code) + async def status_code(self, code): + """Return given HTTP Status code.""" + + @GET('response-headers') + @query('first_name', 'firstName') + @query('last_name', 'lastName') + @query('nickname') + async def response_headers(self, first_name, last_name, nickname='httpbin'): + """Return given response headers.""" + + @GET('redirect/{n}') + async def redirect(self, n): + """302 Redirects n times.""" + + @GET('redirect-to') + @query('url') + async def redirect_to(self, url): + """302 Redirects to the foo URL.""" + + @GET('redirect-to') + @query('url') + @query('code', 'status_code') + async def redirect_to_foo(self, url, code): + """307 Redirects to the foo URL.""" + + @GET('relative-redirect/{n}') + async def relative_redirect(self, n): + """302 Relative redirects n times.""" + + @GET('absolute-redirect/{n}') + async def absolute_redirect(self, n): + """302 Absolute redirects n times.""" + + @GET('cookies') + async def cookies(self): + """Return cookie data.""" + + @GET('cookies/set') + async def cookies_set(self): + """Set one or more simple cookies.""" + + @GET('cookies/delete') + async def cookies_delete(self): + """Delete one or more simple cookies.""" + + @GET('basic-auth/{user}/{passwd}') + def basic_auth(self, user, passwd): + """Challenge HTTPBasic Auth.""" + + @GET('hidden-basic-auth/{user}/{passwd}') + async def hidden_basic_auth(self, user, passwd): + """404'd BasicAuth.""" + + @GET('digest-auth/{qop}/{user}/{passwd}/{algorithm}/never') + async def digest_auth_algorithm(self, qop, user, passwd, algorithm): + """Challenge HTTP Digest Auth.""" + + @GET('digest-auth/{qop}/{user}/{passwd}') + async def digest_auth(self, qop, user, passwd): + """Challenge HTTP Digest Auth.""" + + @GET('stream/{n}') + @stream + async def stream_n(self, n): + """Stream min(n, 100) lines.""" + + @GET('delay/{n}') + @timeout(2) + async def delay(self, n): + """Delay responding for min(n, 10) seconds.""" + + @GET('drip') + @stream + @query('numbytes') + @query('duration') + @query('delay') + @query('code') + async def drip(self, numbytes, duration, delay, code): + """Drip data over a duration. + + Drip data over a duration after an optional initial delay, then + (optionally) Return with the given status code. + """ + + @GET('range/{n}') + @stream + @query('duration') + @query('chunk_size') + async def range(self, n, duration, chunk_size): + """Stream n bytes. + + Stream n bytes, and allows specifying a Range header to select + Parameterof the data. Accepts a chunk_size and request duration + parameter. + """ + + @GET('html') + @accept('text/html') + async def html(self): + """Render an HTML Page.""" + + @GET('robots.txt') + async def robots_txt(self): + """Return some robots.txt rules.""" + + @GET('deny') + async def deny(self): + """Denied by robots.txt file.""" + + @GET('cache') + async def cache(self): + """Return 200 unless an If-Modified-Since. + + Return 200 unless an If-Modified-Since or If-None-Match header + is provided, when it Return a 304. + """ + + @GET('etag/{etag}') + async def etag(self, etag): + """Assume the resource has the given etag. + + Assume the resource has the given etag and responds to + If-None-Match header with a 200 or 304 and If-Match with a 200 + or 412 as appropriate. + """ + + @GET('cache/{n}') + async def cache_n(self, n): + """Set a Cache-Control header for n seconds.""" + + @GET('bytes/{n}') + async def bytes(self, n): + """Generate n random bytes. + + Generate n random bytes of binary data, accepts optional seed + integer parameter. + """ + + @GET('stream-bytes/{n}') + async def stream_bytes(self, n): + """Stream n random bytes. + + Stream n random bytes of binary data in chunked encoding, accepts + optional seed and chunk_size integer parameters. + """ + + @GET('links/{n}') + @accept('text/html') + async def links(self, n): + """Return page containing n HTML links.""" + + @GET('image') + async def image(self): + """Return page containing an image based on sent Accept header.""" + + @GET('/image/png') + @accept('image/png') + @on(200, parse_image) + async def image_png(self): + """Return a PNG image.""" + + @GET('/image/jpeg') + @accept('image/jpeg') + @on(200, parse_image) + async def image_jpeg(self): + """Return a JPEG image.""" + + @GET('/image/webp') + @accept('image/webp') + @on(200, parse_image) + async def image_webp(self): + """Return a WEBP image.""" + + @GET('/image/svg') + @accept('image/svg') + async def image_svg(self): + """Return a SVG image.""" + + @POST('forms/post') + @body('forms') + async def forms_post(self, forms): + """HTML form that submits to /post.""" + + @GET('xml') + @accept('application/xml') + async def xml(self): + """Return some XML.""" diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py new file mode 100644 index 0000000..6b8bfde --- /dev/null +++ b/tests/httpbin_async_test.py @@ -0,0 +1,677 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2018-2021 Bartosz Kryza +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pprint + +import pytest +import time +import os +import sys +import json + +import httpx + +from requests.structures import CaseInsensitiveDict + +from decorest import __version__, HttpStatus, HTTPErrorWrapper +from requests import cookies +from requests.exceptions import ReadTimeout +from requests.auth import HTTPBasicAuth, HTTPDigestAuth +from requests_toolbelt.multipart.encoder import MultipartEncoder +import xml.etree.ElementTree as ET + +sys.path.append(os.path.dirname(os.path.realpath(__file__)) + "/../examples") +from httpbin.httpbin_async_client import HttpBinAsyncClient, parse_image + + +@pytest.fixture +def client() -> HttpBinAsyncClient: + # Give Docker and HTTPBin some time to spin up + time.sleep(2) + + host = os.environ["HTTPBIN_HOST"] + port = os.environ["HTTPBIN_80_TCP_PORT"] + + return HttpBinAsyncClient("http://{host}:{port}".format(host=host, port=port), + backend='httpx') + + +@pytest.fixture +def basic_auth_client(): + # Give Docker and HTTPBin some time to spin up + host = os.environ["HTTPBIN_HOST"] + port = os.environ["HTTPBIN_80_TCP_PORT"] + + client = HttpBinAsyncClient("http://{host}:{port}".format(host=host, port=port), + backend='httpx') + client._set_auth(HTTPBasicAuth('user', 'password')) + + return client + + +@pytest.mark.asyncio +async def test_ip(client): + """ + """ + res = await client.ip() + + assert "origin" in res + + +@pytest.mark.asyncio +async def test_ip_repeat(client): + """ + """ + res = await client.ip_repeat() + + for ip in res: + assert "origin" in ip + + +@pytest.mark.asyncio +async def test_uuid(client): + """ + """ + res = await client.uuid() + + assert "uuid" in res + + +@pytest.mark.asyncio +async def test_user_agent(client): + """ + """ + res = await client.user_agent() + + assert res['user-agent'] == 'decorest/{v}'.format(v=__version__) + + +@pytest.mark.asyncio +async def test_headers(client): + """ + """ + + def ci(d): + return CaseInsensitiveDict(d) + + # Check + res = await client.headers(header={'A': 'AA', 'B': 'CC'}) + + assert ci(res['headers'])['User-Agent'] == 'decorest/{v}'.format(v=__version__) + assert ci(res['headers'])['A'] == 'AA' + assert ci(res['headers'])['B'] == 'CC' + + # Check with other values + res = await client.headers(header={'A': 'DD', 'B': 'EE'}) + + assert ci(res['headers'])['A'] == 'DD' + assert ci(res['headers'])['B'] == 'EE' + + # Check method default header value + res = await client.headers() + + assert ci(res['headers'])['B'] == 'BB' + + # Check passing header value in arguments + res = await client.headers_in_args('1234', 'ABCD') + + assert ci(res['headers'])['First'] == '1234' + assert ci(res['headers'])['SecondHeader'] == 'ABCD' + + +@pytest.mark.asyncio +async def test_get(client): + """ + """ + data = {"a": "b", "c": "1"} + res = await client.get(query=data) + + assert res["args"] == data + + +@pytest.mark.asyncio +async def test_post(client): + """ + """ + data = {"a": "b", "c": "1"} + res = await client.post(data, content='application/json', query=data) + + assert res["args"] == data + assert res["json"] == data + + +@pytest.mark.asyncio +async def test_post_form(client): + """ + """ + res = await client.post_form("value1", "value2", "value3") + + assert res["form"]["key1"] == "value1" + assert res["form"]["key2"] == "value2" + assert res["form"]["key3"] == "value3" + + +@pytest.mark.asyncio +async def test_post_multipart(client): + """ + """ + f = 'tests/testdata/multipart.dat' + + res = await client.post( + None, files={'test': ('filename', open(f, 'rb'), 'text/plain')}) + + assert res["files"]["test"] == open(f, 'rb').read().decode("utf-8") + + +@pytest.mark.asyncio +async def test_post_multipart_decorators(client): + """ + """ + f = 'tests/testdata/multipart.dat' + res = await client.post_multipart(bytes('TEST1', 'utf-8'), + bytes('TEST2', 'utf-8'), + ('filename', open(f, 'rb'), + 'text/plain')) + + assert res["files"]["part1"] == 'TEST1' + assert res["files"]["part2"] == 'TEST2' + assert res["files"]["test"] == open(f, 'rb').read().decode("utf-8") + + +@pytest.mark.asyncio +async def test_patch(client): + """ + """ + data = "ABCD" + res = await client.patch(data, content="text/plain") + + assert res["data"] == data + + +@pytest.mark.asyncio +async def test_put(client): + """ + """ + data = {"a": "b", "c": "1"} + res = await client.put(data, content="application/json", query=data) + + assert res["args"] == data + assert res["json"] == data + + +@pytest.mark.asyncio +async def test_delete(client): + """ + """ + data = {"a": "b", "c": "1"} + await client.delete(query=data) + + +@pytest.mark.asyncio +async def test_anything(client): + """ + """ + data = {"a": "b", "c": "1"} + res = await client.anything(data, content="application/json", query=data) + + assert res["args"] == data + assert res["json"] == data + + +@pytest.mark.asyncio +async def test_anything_anything(client): + """ + """ + data = {"a": "b", "c": "1"} + res = await client.anything_anything("something", + data, + content="application/json", + query=data) + + assert res["args"] == data + assert res["json"] == data + + +@pytest.mark.asyncio +async def test_encoding_utf(client): + """ + """ + # TODO - add charset decorator + + +@pytest.mark.asyncio +async def test_gzip(client): + """ + """ + res = await client.gzip() + + assert json.loads(res)['gzipped'] is True + + +@pytest.mark.asyncio +async def test_deflate(client): + """ + """ + res = await client.deflate() + + assert json.loads(res)['deflated'] is True + + +@pytest.mark.asyncio +async def test_brotli(client): + """ + """ + res = await client.brotli() + + assert json.loads(res)['brotli'] is True + + +@pytest.mark.asyncio +async def test_status(client): + """ + """ + assert 418 == await client.status_code(418) + + +@pytest.mark.asyncio +async def test_response_headers(client): + """ + """ + res = await client.response_headers('HTTP', 'BIN') + + assert res['firstName'] == 'HTTP' + assert res['lastName'] == 'BIN' + assert res['nickname'] == 'httpbin' + + +@pytest.mark.asyncio +async def test_redirect(client): + """ + """ + res = await client.redirect(2, + on={302: lambda r: 'REDIRECTED'}, + follow_redirects=False) + + assert res == 'REDIRECTED' + + +@pytest.mark.asyncio +async def test_redirect_to(client): + """ + """ + res = await client.redirect_to('http://httpbin.org', + on={302: lambda r: 'REDIRECTED'}, + follow_redirects=False) + + assert res == 'REDIRECTED' + + +@pytest.mark.asyncio +async def test_redirect_to_foo(client): + """ + """ + res = await client.redirect_to_foo('http://httpbin.org', + 307, + on={307: lambda r: 'REDIRECTED'}, + follow_redirects=False) + + assert res == 'REDIRECTED' + + +@pytest.mark.asyncio +async def test_relative_redirect(client): + """ + """ + res = await client.relative_redirect(1, + on={302: lambda r: r.headers['Location']}, + follow_redirects=False) + + assert res == '/get' + + +@pytest.mark.asyncio +async def test_absolute_redirect(client): + """ + """ + res = await client.absolute_redirect(1, + on={302: lambda r: r.headers['Location']}, + follow_redirects=False) + + assert res.endswith('/get') + + +@pytest.mark.asyncio +async def test_cookies(client): + """ + """ + jar = cookies.RequestsCookieJar() + jar.set('cookie1', 'A', path='/cookies') + jar.set('cookie2', 'B', path='/fruits') + res = await client.cookies(cookies=jar) + + assert res['cookies']['cookie1'] == 'A' + assert 'cookie2' not in res['cookies'] + + +@pytest.mark.asyncio +async def test_cookies_set(client): + """ + """ + res = await client.cookies_set( + query={"cookie1": "A", "cookie2": "B"}, + follow_redirects=True) + + assert res["cookies"]["cookie1"] == "A" + assert res["cookies"]["cookie2"] == "B" + + +@pytest.mark.asyncio +async def test_cookies_session(client): + """ + """ + s = client._async_session() + pprint.pprint(type(s)) + res = await s.cookies_set( + query={"cookie1": "A", "cookie2": "B"}, + follow_redirects=True) + + assert res["cookies"]["cookie1"] == "A" + assert res["cookies"]["cookie2"] == "B" + + res = await s.cookies() + + assert res["cookies"]["cookie1"] == "A" + assert res["cookies"]["cookie2"] == "B" + + s._close() + + +@pytest.mark.asyncio +async def test_cookies_session_with_contextmanager(client): + """ + """ + async with client._async_session() as s: + s._requests_session.verify = False + res = await s.cookies_set( + query={"cookie1": "A", "cookie2": "B"}, + follow_redirects=True) + + pprint.pprint(res) + + assert res["cookies"]["cookie1"] == "A" + assert res["cookies"]["cookie2"] == "B" + + res = await s.cookies(follow_redirects=False) + + pprint.pprint(res) + + assert res["cookies"]["cookie1"] == "A" + assert res["cookies"]["cookie2"] == "B" + + +@pytest.mark.asyncio +async def test_cookies_delete(client): + """ + """ + client.cookies_set(query={"cookie1": "A", "cookie2": "B"}) + client.cookies_delete(query={"cookie1": None}) + res = await client.cookies() + + assert "cookie1" not in res["cookies"] + + +@pytest.mark.asyncio +async def test_basic_auth(client, basic_auth_client): + """ + """ + with pytest.raises(HTTPErrorWrapper) as e: + res = await client.basic_auth('user', 'password') + + assert isinstance(e.value, HTTPErrorWrapper) + + res = await basic_auth_client.basic_auth('user', 'password') + assert res['authenticated'] is True + + +@pytest.mark.asyncio +async def test_basic_auth_with_session(client, basic_auth_client): + """ + """ + res = None + with basic_auth_client._session() as s: + res = await s.basic_auth('user', 'password') + + assert res['authenticated'] is True + + +@pytest.mark.asyncio +async def test_hidden_basic_auth(client): + """ + """ + res = await client.hidden_basic_auth('user', + 'password', + auth=HTTPBasicAuth('user', 'password')) + + assert res['authenticated'] is True + + +@pytest.mark.asyncio +async def test_digest_auth_algorithm(client): + """ + """ + auth = httpx.DigestAuth('user', 'password') + + res = await client.digest_auth_algorithm('auth', + 'user', + 'password', + 'MD5', + auth=auth) + + assert res['authenticated'] is True + + +@pytest.mark.asyncio +async def test_digest_auth(client): + """ + """ + auth = httpx.DigestAuth('user', 'password') + + res = await client.digest_auth( + 'auth', 'user', 'password', auth=auth) + + assert res['authenticated'] is True + + +@pytest.mark.asyncio +async def test_stream_n(client): + """ + """ + count = 0 + with client.stream_n(5) as r: + for line in r.iter_lines(): + count += 1 + + assert count == 5 + + +@pytest.mark.asyncio +async def test_delay(client): + """ + """ + with pytest.raises(HTTPErrorWrapper): + await client.delay(5) + + try: + await client.delay(1) + await client.delay(3, timeout=4) + except HTTPErrorWrapper: + pytest.fail("Operation should not have timed out") + + +@pytest.mark.asyncio +async def test_drip(client): + """ + """ + content = [] + with client.drip(10, 5, 1, 200) as r: + if client._backend() == 'requests': + for b in r.iter_content(chunk_size=1): + await content.append(b) + else: + for b in r.iter_raw(): + await content.append(b) + + assert len(content) == 10 + + +@pytest.mark.asyncio +async def test_range(client): + """ + """ + content = [] + + with client.range(128, 1, 2, header={"Range": "bytes=10-19"}) as r: + if client._backend() == 'requests': + for b in r.iter_content(chunk_size=2): + await content.append(b) + else: + for b in r.iter_raw(): + await content.append(b) + + assert len(content) == 5 + + +@pytest.mark.asyncio +async def test_html(client): + """ + """ + res = await client.html( + on={200: lambda r: (r.headers['content-type'], r.content)}) + + assert res[0] == 'text/html; charset=utf-8' + assert res[1].decode("utf-8").count( + '

Herman Melville - Moby-Dick

') == 1 + + +@pytest.mark.asyncio +async def test_robots_txt(client): + """ + """ + res = await client.robots_txt() + + assert "Disallow: /deny" in res + + +@pytest.mark.asyncio +async def test_deny(client): + """ + """ + res = await client.deny() + + assert "YOU SHOULDN'T BE HERE" in res + + +@pytest.mark.asyncio +async def test_cache(client): + """ + """ + status_code = await client.cache( + header={'If-Modified-Since': 'Sat, 16 Aug 2015 08:00:00 GMT'}, + on={HttpStatus.ANY: lambda r: r.status_code}) + + assert status_code == 304 + + +@pytest.mark.asyncio +async def test_cache_n(client): + """ + """ + res = await client.cache_n(10) + + assert 'Cache-Control' not in res['headers'] + + +@pytest.mark.asyncio +async def test_etag(client): + """ + """ + status_code = await client.etag('etag', + header={'If-Match': 'notetag'}, + on={HttpStatus.ANY: lambda r: r.status_code}) + + assert status_code == 412 + + status_code = await client.etag('etag', + header={'If-Match': 'etag'}, + on={HttpStatus.ANY: lambda r: r.status_code}) + + assert status_code == 200 + + +@pytest.mark.asyncio +async def test_bytes(client): + """ + """ + content = await client.stream_bytes(128) + + assert len(content) == 128 + + +@pytest.mark.asyncio +async def test_stream_bytes(client): + """ + """ + content = await client.stream_bytes(128) + + assert len(content) == 128 + + +@pytest.mark.asyncio +async def test_links(client): + """ + """ + html = await client.links(10) + + assert html.count('href') == 10 - 1 + + +@pytest.mark.asyncio +async def test_image(client): + """ + """ + img = await client.image(accept='image/jpeg', on={200: parse_image}) + + assert img.format == 'JPEG' + + img = await client.image_png() + + assert img.format == 'PNG' + + img = await client.image_jpeg() + + assert img.format == 'JPEG' + + img = await client.image_webp() + + assert img.format == 'WEBP' + + +@pytest.mark.asyncio +async def test_xml(client): + """ + """ + slideshow = await client.xml(on={200: lambda r: ET.fromstring(r.text)}) + + assert slideshow.tag == 'slideshow' diff --git a/tox.ini b/tox.ini index 48be408..aaf560b 100644 --- a/tox.ini +++ b/tox.ini @@ -54,6 +54,8 @@ deps = commands = py.test -v --cov=decorest [] tests/petstore_test.py [testenv:httpbin] +setenv = + HTTPX_LOG_LEVEL=trace docker = httpbin deps = @@ -65,7 +67,24 @@ deps = httpx Pillow brotli -commands = py.test -v --cov=decorest [] tests/httpbin_test.py +commands = py.test -v --cov=decorest [] tests/httpbin_test.py -s -k test_cookies_session_with_contextmanager + +[testenv:asynchttpbin] +setenv = + HTTPX_LOG_LEVEL=trace +docker = + httpbin +deps = + pytest + pytest-cov + pytest-asyncio + requests + requests-toolbelt + typing-extensions + httpx + Pillow + brotli +commands = py.test -v --cov=decorest [] tests/httpbin_async_test.py -s -k test_cookies_session_with_contextmanager [docker:httpbin] image = kennethreitz/httpbin From b752ae0299acedf1e3146495f7fe671f468d82a1 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Fri, 24 Dec 2021 00:27:32 +0100 Subject: [PATCH 10/48] Fixed asyncio httpbin tests --- decorest/decorators.py | 38 +++++++++++----------- examples/httpbin/httpbin_async_client.py | 2 +- tests/httpbin_async_test.py | 41 ++++++++++-------------- tox.ini | 6 ++-- 4 files changed, 38 insertions(+), 49 deletions(-) diff --git a/decorest/decorators.py b/decorest/decorators.py index a255a19..a872ae4 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -501,7 +501,7 @@ def __init__(self, func, path_template, args, kwargs): # If '__session' was passed in the kwargs, execute this request # using the session context, otherwise execute directly via the - # requests module + # requests or httpx module if self.session: self.execution_context = self.session else: @@ -592,11 +592,15 @@ async def call_async(self, func: typing.Callable[..., typing.Any], http_request = HttpRequest(func, self.path_template, args, kwargs) try: + pprint.pprint(http_request) if http_request.http_method == HttpMethod.GET \ and http_request.is_stream: del kwargs['stream'] - result \ - = await http_request.execution_context.stream("GET", http_request.req, **http_request.kwargs) + req = http_request.execution_context.build_request( + 'GET', http_request.req, **http_request.kwargs) + + result = await http_request.execution_context.send(req, + stream=True) else: if http_request.http_method == HttpMethod.POST \ and http_request.is_multipart_request: @@ -621,8 +625,9 @@ def call(self, func: typing.Callable[..., typing.Any], and http_request.http_method == HttpMethod.GET \ and http_request.is_stream: del kwargs['stream'] - result \ - = http_request.execution_context.stream("GET", http_request.req, **http_request.kwargs) + + result = http_request.execution_context.stream( + "GET", http_request.req, **http_request.kwargs) else: if http_request.http_method == HttpMethod.POST \ and http_request.is_multipart_request: @@ -673,18 +678,11 @@ async def __dispatch_async(self, http_request: HttpRequest) -> typing.Any: import httpx - async with httpx.AsyncClient() as client: - if method == 'get': - response = await client.get(http_request.req, **http_request.kwargs) - elif method == 'post': - response = await client.post(http_request.req, **http_request.kwargs) - elif method == 'put': - response = await client.put(http_request.req, **http_request.kwargs) - elif method == 'patch': - response = await client.patch(http_request.req, **http_request.kwargs) - elif method == 'head': - response = await client.head(http_request.req, **http_request.kwargs) - elif method == 'delete': - response = await client.delete(http_request.req, **http_request.kwargs) - - return response + if not isinstance(http_request.execution_context, httpx.AsyncClient): + async with httpx.AsyncClient() as client: + return await client.request(method.upper(), + http_request.req, + **http_request.kwargs) + else: + return await http_request.execution_context.request( + method.upper(), http_request.req, **http_request.kwargs) diff --git a/examples/httpbin/httpbin_async_client.py b/examples/httpbin/httpbin_async_client.py index b5db6d6..f0d377f 100644 --- a/examples/httpbin/httpbin_async_client.py +++ b/examples/httpbin/httpbin_async_client.py @@ -200,7 +200,7 @@ async def cookies_delete(self): """Delete one or more simple cookies.""" @GET('basic-auth/{user}/{passwd}') - def basic_auth(self, user, passwd): + async def basic_auth(self, user, passwd): """Challenge HTTPBasic Auth.""" @GET('hidden-basic-auth/{user}/{passwd}') diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index 6b8bfde..4f469ea 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -395,7 +395,7 @@ async def test_cookies_session(client): assert res["cookies"]["cookie1"] == "A" assert res["cookies"]["cookie2"] == "B" - s._close() + await s._close() @pytest.mark.asyncio @@ -408,15 +408,11 @@ async def test_cookies_session_with_contextmanager(client): query={"cookie1": "A", "cookie2": "B"}, follow_redirects=True) - pprint.pprint(res) - assert res["cookies"]["cookie1"] == "A" assert res["cookies"]["cookie2"] == "B" res = await s.cookies(follow_redirects=False) - pprint.pprint(res) - assert res["cookies"]["cookie1"] == "A" assert res["cookies"]["cookie2"] == "B" @@ -425,8 +421,10 @@ async def test_cookies_session_with_contextmanager(client): async def test_cookies_delete(client): """ """ - client.cookies_set(query={"cookie1": "A", "cookie2": "B"}) - client.cookies_delete(query={"cookie1": None}) + await client.cookies_set(query={"cookie1": "A", "cookie2": "B"}, + follow_redirects=True) + await client.cookies_delete(query={"cookie1": None}, + follow_redirects=True) res = await client.cookies() assert "cookie1" not in res["cookies"] @@ -499,8 +497,9 @@ async def test_stream_n(client): """ """ count = 0 - with client.stream_n(5) as r: - for line in r.iter_lines(): + async with client._async_session() as s: + r = await s.stream_n(5) + async for _ in r.aiter_lines(): count += 1 assert count == 5 @@ -525,13 +524,10 @@ async def test_drip(client): """ """ content = [] - with client.drip(10, 5, 1, 200) as r: - if client._backend() == 'requests': - for b in r.iter_content(chunk_size=1): - await content.append(b) - else: - for b in r.iter_raw(): - await content.append(b) + async with client._async_session() as s: + r = await s.drip(10, 5, 1, 200) + async for b in r.aiter_raw(): + content.append(b) assert len(content) == 10 @@ -542,13 +538,10 @@ async def test_range(client): """ content = [] - with client.range(128, 1, 2, header={"Range": "bytes=10-19"}) as r: - if client._backend() == 'requests': - for b in r.iter_content(chunk_size=2): - await content.append(b) - else: - for b in r.iter_raw(): - await content.append(b) + async with client._async_session() as s: + r = await s.range(128, 1, 2, header={"Range": "bytes=10-19"}) + async for b in r.aiter_raw(): + content.append(b) assert len(content) == 5 @@ -642,7 +635,7 @@ async def test_stream_bytes(client): async def test_links(client): """ """ - html = await client.links(10) + html = await client.links(10, follow_redirects=True) assert html.count('href') == 10 - 1 diff --git a/tox.ini b/tox.ini index aaf560b..92bfdcd 100644 --- a/tox.ini +++ b/tox.ini @@ -67,11 +67,9 @@ deps = httpx Pillow brotli -commands = py.test -v --cov=decorest [] tests/httpbin_test.py -s -k test_cookies_session_with_contextmanager +commands = py.test -v --cov=decorest [] tests/httpbin_test.py [testenv:asynchttpbin] -setenv = - HTTPX_LOG_LEVEL=trace docker = httpbin deps = @@ -84,7 +82,7 @@ deps = httpx Pillow brotli -commands = py.test -v --cov=decorest [] tests/httpbin_async_test.py -s -k test_cookies_session_with_contextmanager +commands = py.test -v --cov=decorest [] tests/httpbin_async_test.py -W error::RuntimeWarning -W error::UserWarning [docker:httpbin] image = kennethreitz/httpbin From 15fbe738e94cf5861426b57d82948cefdefc08ec Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Fri, 24 Dec 2021 00:38:30 +0100 Subject: [PATCH 11/48] Fixed flake8 complaints --- decorest/HEAD.py | 4 +++- decorest/OPTIONS.py | 3 ++- decorest/PATCH.py | 3 ++- decorest/client.py | 3 ++- decorest/decorators.py | 48 +++++++++++++++++++++++++++++------------- 5 files changed, 42 insertions(+), 19 deletions(-) diff --git a/decorest/HEAD.py b/decorest/HEAD.py index 10ed1f0..14804dc 100644 --- a/decorest/HEAD.py +++ b/decorest/HEAD.py @@ -36,9 +36,11 @@ def __call__(self, func: TDecor) -> TDecor: @wraps(func) async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: - return await super(HEAD, self).call_async(func, *args, **kwargs) + return await super(HEAD, self).call_async( + func, *args, **kwargs) return typing.cast(TDecor, get_decorator) + @wraps(func) def head_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: diff --git a/decorest/OPTIONS.py b/decorest/OPTIONS.py index b59f0c2..4cf21bc 100644 --- a/decorest/OPTIONS.py +++ b/decorest/OPTIONS.py @@ -36,7 +36,8 @@ def __call__(self, func: TDecor) -> TDecor: @wraps(func) async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: - return await super(OPTIONS, self).call_async(func, *args, **kwargs) + return await super(OPTIONS, self).call_async( + func, *args, **kwargs) return typing.cast(TDecor, get_decorator) diff --git a/decorest/PATCH.py b/decorest/PATCH.py index 22e878e..6541e34 100644 --- a/decorest/PATCH.py +++ b/decorest/PATCH.py @@ -36,7 +36,8 @@ def __call__(self, func: TDecor) -> TDecor: @wraps(func) async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: - return await super(PATCH, self).call_async(func, *args, **kwargs) + return await super(PATCH, self).call_async( + func, *args, **kwargs) return typing.cast(TDecor, get_decorator) diff --git a/decorest/client.py b/decorest/client.py index cb6f643..877486e 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -104,7 +104,8 @@ def __getattr__(self, name: str) -> typing.Any: if name == '_close': return self.__session.aclose - async def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + async def invoker(*args: typing.Any, + **kwargs: typing.Any) -> typing.Any: kwargs['__session'] = self.__session assert asyncio.iscoroutinefunction(getattr(self.__client, name)) return await getattr(self.__client, name)(*args, **kwargs) diff --git a/decorest/decorators.py b/decorest/decorators.py index a872ae4..701cf20 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -321,7 +321,9 @@ def stream(t: TDecor) -> TDecor: # return execution_context, on_handlers, rest_client class HttpRequest: """ - Class representing an HTTP request created based on the decorators and arguments. + HTTP request wrapper. + + Class representing an HTTP request created from decorators and arguments. """ http_method: str is_multipart_request: bool @@ -331,18 +333,29 @@ class HttpRequest: on_handlers: typing.Mapping[int, typing.Callable[..., typing.Any]] session: str execution_context: typing.Any - rest_client: 'RestClient' + rest_client: 'RestClient' # noqa def __init__(self, func, path_template, args, kwargs): + """ + Construct HttpRequest instance. + + Args: + func - decorated function + path_template - template for creating the request path + args - arguments + kwargs - named arguments + """ self.http_method = get_method_decor(func) self.path_template = path_template self.kwargs = kwargs - if self.http_method not in (HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, - HttpMethod.PATCH, HttpMethod.DELETE, - HttpMethod.HEAD, HttpMethod.OPTIONS): + if self.http_method not in (HttpMethod.GET, HttpMethod.POST, + HttpMethod.PUT, HttpMethod.PATCH, + HttpMethod.DELETE, HttpMethod.HEAD, + HttpMethod.OPTIONS): raise ValueError( - 'Unsupported HTTP method: {method}'.format(method=self.http_method)) + 'Unsupported HTTP method: {method}'.format( + method=self.http_method)) self.rest_client = args[0] args_dict = dict_from_args(func, *args) @@ -382,7 +395,7 @@ def __init__(self, func, path_template, args, kwargs): auth = self.rest_client._auth() # Get status handlers self.on_handlers = merge_dicts(get_on_decor(self.rest_client.__class__), - get_on_decor(func)) + get_on_decor(func)) # Get timeout request_timeout = get_timeout_decor(self.rest_client.__class__) if get_timeout_decor(func): @@ -422,7 +435,8 @@ def __init__(self, func, path_template, args, kwargs): del self.kwargs['multipart'] elif decor == 'on': self.__validate_decor(decor, self.kwargs, dict) - self.on_handlers = merge_dicts(self.on_handlers, self.kwargs['on']) + self.on_handlers = merge_dicts(self.on_handlers, + self.kwargs['on']) del self.kwargs['on'] elif decor == 'accept': self.__validate_decor(decor, self.kwargs, str) @@ -430,10 +444,12 @@ def __init__(self, func, path_template, args, kwargs): del self.kwargs['accept'] elif decor == 'content': self.__validate_decor(decor, self.kwargs, str) - header_parameters['content-type'] = self.kwargs['content'] + header_parameters['content-type'] \ + = self.kwargs['content'] del self.kwargs['content'] elif decor == 'timeout': - self.__validate_decor(decor, self.kwargs, numbers.Number) + self.__validate_decor(decor, self.kwargs, + numbers.Number) request_timeout = self.kwargs['timeout'] del self.kwargs['timeout'] elif decor == 'stream': @@ -455,8 +471,9 @@ def __init__(self, func, path_template, args, kwargs): self.kwargs['files'] = multipart_parameters elif self.rest_client._backend() == 'requests': from requests_toolbelt.multipart.encoder import MultipartEncoder - self.is_multipart_request = 'data' in self.kwargs and not isinstance( - self.kwargs['data'], MultipartEncoder) + self.is_multipart_request = \ + 'data' in self.kwargs and \ + not isinstance(self.kwargs['data'], MultipartEncoder) else: self.is_multipart_request = 'files' in self.kwargs @@ -549,6 +566,7 @@ def __merge_args(self, args_dict: ArgsDict, return parameters def handle(self, result): + """Handle result response.""" if self.on_handlers and result.status_code in self.on_handlers: # Use a registered handler for the returned status code return self.on_handlers[result.status_code](result) @@ -587,8 +605,9 @@ def __init__(self, path: str): self.path_template = path async def call_async(self, func: typing.Callable[..., typing.Any], - *args: typing.Any, **kwargs: typing.Any) -> typing.Any: - + *args: typing.Any, + **kwargs: typing.Any) -> typing.Any: + """Execute async HTTP request.""" http_request = HttpRequest(func, self.path_template, args, kwargs) try: @@ -617,7 +636,6 @@ async def call_async(self, func: typing.Callable[..., typing.Any], def call(self, func: typing.Callable[..., typing.Any], *args: typing.Any, **kwargs: typing.Any) -> typing.Any: """Execute the API HTTP request.""" - http_request = HttpRequest(func, self.path_template, args, kwargs) try: From 0d2bf7c8d256249155440a3cfd787f68e4490c1d Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Fri, 24 Dec 2021 13:53:26 +0100 Subject: [PATCH 12/48] Fixed async httbin tests --- .github/workflows/workflow.yml | 2 +- decorest/DELETE.py | 9 +- decorest/GET.py | 8 +- decorest/HEAD.py | 8 +- decorest/OPTIONS.py | 8 +- decorest/PATCH.py | 8 +- decorest/POST.py | 11 +- decorest/PUT.py | 8 +- decorest/__init__.py | 7 +- decorest/client.py | 2 +- decorest/decorator_utils.py | 145 ++++++++++++ decorest/decorators.py | 408 +-------------------------------- decorest/request.py | 313 +++++++++++++++++++++++++ tests/decorators_tests.py | 3 +- 14 files changed, 506 insertions(+), 434 deletions(-) create mode 100644 decorest/decorator_utils.py create mode 100644 decorest/request.py diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 24addc5..8b1b087 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -28,4 +28,4 @@ jobs: python-version: ${{ matrix.python-version }} - run: pip install tox tox-gh-actions tox-docker==$TOX_DOCKER_VERSION - - run: tox -c tox.ini -e flake8,mypy,basic,swaggerpetstore,httpbin \ No newline at end of file + - run: tox -c tox.ini -e flake8,mypy,basic,swaggerpetstore,httpbin,asynchttpbin \ No newline at end of file diff --git a/decorest/DELETE.py b/decorest/DELETE.py index 59c9c6a..cd5ed58 100644 --- a/decorest/DELETE.py +++ b/decorest/DELETE.py @@ -18,7 +18,8 @@ import typing from functools import wraps -from .decorators import HttpMethodDecorator, set_decor +from .decorator_utils import set_decor +from .decorators import HttpMethodDecorator from .types import HttpMethod, TDecor @@ -34,13 +35,13 @@ def __call__(self, func: TDecor) -> TDecor: if asyncio.iscoroutinefunction(func): @wraps(func) - async def delete_decorator(*args: typing.Any, - **kwargs: typing.Any) \ + async def async_delete_decorator(*args: typing.Any, + **kwargs: typing.Any) \ -> typing.Any: return await super(DELETE, self).call_async(func, *args, **kwargs) - return typing.cast(TDecor, delete_decorator) + return typing.cast(TDecor, async_delete_decorator) @wraps(func) def delete_decorator(*args: typing.Any, **kwargs: typing.Any) \ diff --git a/decorest/GET.py b/decorest/GET.py index d484cf3..1b63c74 100644 --- a/decorest/GET.py +++ b/decorest/GET.py @@ -18,7 +18,8 @@ import typing from functools import wraps -from .decorators import HttpMethodDecorator, set_decor +from .decorator_utils import set_decor +from .decorators import HttpMethodDecorator from .types import HttpMethod, TDecor @@ -34,11 +35,12 @@ def __call__(self, func: TDecor) -> TDecor: if asyncio.iscoroutinefunction(func): @wraps(func) - async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ + async def async_get_decorator(*args: typing.Any, + **kwargs: typing.Any) \ -> typing.Any: return await super(GET, self).call_async(func, *args, **kwargs) - return typing.cast(TDecor, get_decorator) + return typing.cast(TDecor, async_get_decorator) @wraps(func) def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ diff --git a/decorest/HEAD.py b/decorest/HEAD.py index 14804dc..e19ba8d 100644 --- a/decorest/HEAD.py +++ b/decorest/HEAD.py @@ -18,7 +18,8 @@ import typing from functools import wraps -from .decorators import HttpMethodDecorator, set_decor +from .decorator_utils import set_decor +from .decorators import HttpMethodDecorator from .types import HttpMethod, TDecor @@ -34,12 +35,13 @@ def __call__(self, func: TDecor) -> TDecor: if asyncio.iscoroutinefunction(func): @wraps(func) - async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ + async def async_head_decorator(*args: typing.Any, + **kwargs: typing.Any) \ -> typing.Any: return await super(HEAD, self).call_async( func, *args, **kwargs) - return typing.cast(TDecor, get_decorator) + return typing.cast(TDecor, async_head_decorator) @wraps(func) def head_decorator(*args: typing.Any, **kwargs: typing.Any) \ diff --git a/decorest/OPTIONS.py b/decorest/OPTIONS.py index 4cf21bc..f633d03 100644 --- a/decorest/OPTIONS.py +++ b/decorest/OPTIONS.py @@ -18,7 +18,8 @@ import typing from functools import wraps -from .decorators import HttpMethodDecorator, set_decor +from .decorator_utils import set_decor +from .decorators import HttpMethodDecorator from .types import HttpMethod, TDecor @@ -34,12 +35,13 @@ def __call__(self, func: TDecor) -> TDecor: if asyncio.iscoroutinefunction(func): @wraps(func) - async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ + async def async_options_decorator(*args: typing.Any, + **kwargs: typing.Any) \ -> typing.Any: return await super(OPTIONS, self).call_async( func, *args, **kwargs) - return typing.cast(TDecor, get_decorator) + return typing.cast(TDecor, async_options_decorator) @wraps(func) def options_decorator(*args: typing.Any, **kwargs: typing.Any) \ diff --git a/decorest/PATCH.py b/decorest/PATCH.py index 6541e34..be395c3 100644 --- a/decorest/PATCH.py +++ b/decorest/PATCH.py @@ -18,7 +18,8 @@ import typing from functools import wraps -from .decorators import HttpMethodDecorator, set_decor +from .decorator_utils import set_decor +from .decorators import HttpMethodDecorator from .types import HttpMethod, TDecor @@ -34,12 +35,13 @@ def __call__(self, func: TDecor) -> TDecor: if asyncio.iscoroutinefunction(func): @wraps(func) - async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ + async def async_patch_decorator(*args: typing.Any, + **kwargs: typing.Any) \ -> typing.Any: return await super(PATCH, self).call_async( func, *args, **kwargs) - return typing.cast(TDecor, get_decorator) + return typing.cast(TDecor, async_patch_decorator) @wraps(func) def patch_decorator(*args: typing.Any, **kwargs: typing.Any) \ diff --git a/decorest/POST.py b/decorest/POST.py index 3a9812e..2a15354 100644 --- a/decorest/POST.py +++ b/decorest/POST.py @@ -18,7 +18,8 @@ import typing from functools import wraps -from .decorators import HttpMethodDecorator, set_decor +from .decorator_utils import set_decor +from .decorators import HttpMethodDecorator from .types import HttpMethod, TDecor @@ -34,11 +35,13 @@ def __call__(self, func: TDecor) -> TDecor: if asyncio.iscoroutinefunction(func): @wraps(func) - async def post_decorator(*args: typing.Any, **kwargs: typing.Any) \ + async def async_post_decorator(*args: typing.Any, + **kwargs: typing.Any) \ -> typing.Any: - return await super(POST, self).call_async(func, *args, **kwargs) + return await super(POST, self).call_async(func, *args, + **kwargs) - return typing.cast(TDecor, post_decorator) + return typing.cast(TDecor, async_post_decorator) @wraps(func) def post_decorator(*args: typing.Any, **kwargs: typing.Any) \ diff --git a/decorest/PUT.py b/decorest/PUT.py index 7c80749..28e2d3c 100644 --- a/decorest/PUT.py +++ b/decorest/PUT.py @@ -18,7 +18,8 @@ import typing from functools import wraps -from .decorators import HttpMethodDecorator, set_decor +from .decorator_utils import set_decor +from .decorators import HttpMethodDecorator from .types import HttpMethod, TDecor @@ -34,11 +35,12 @@ def __call__(self, func: TDecor) -> TDecor: if asyncio.iscoroutinefunction(func): @wraps(func) - async def get_decorator(*args: typing.Any, **kwargs: typing.Any) \ + async def async_put_decorator(*args: typing.Any, + **kwargs: typing.Any) \ -> typing.Any: return await super(PUT, self).call_async(func, *args, **kwargs) - return typing.cast(TDecor, get_decorator) + return typing.cast(TDecor, async_put_decorator) @wraps(func) def put_decorator(*args: typing.Any, **kwargs: typing.Any) \ diff --git a/decorest/__init__.py b/decorest/__init__.py index 3c45952..0df6ea3 100644 --- a/decorest/__init__.py +++ b/decorest/__init__.py @@ -26,13 +26,14 @@ from .decorators import accept, body, content, endpoint, form, header from .decorators import multipart, on, query, stream, timeout from .errors import HTTPErrorWrapper +from .request import HttpRequest from .types import HttpMethod, HttpStatus __all__ = [ 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'RestClient', - 'HTTPErrorWrapper', 'HttpMethod', 'HttpStatus', 'query', 'body', 'header', - 'on', 'accept', 'content', 'endpoint', 'timeout', 'stream', 'form', - 'multipart' + 'HTTPErrorWrapper', 'HttpMethod', 'HttpStatus', 'HttpRequest', + 'query', 'body', 'header', 'on', 'accept', 'content', 'endpoint', + 'timeout', 'stream', 'form', 'multipart' ] __version__ = "0.1.0" diff --git a/decorest/client.py b/decorest/client.py index 877486e..a38d388 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -23,7 +23,7 @@ import typing import urllib.parse -from .decorators import get_decor +from .decorator_utils import get_decor from .types import AuthTypes, Backends, SessionTypes from .utils import normalize_url diff --git a/decorest/decorator_utils.py b/decorest/decorator_utils.py new file mode 100644 index 0000000..0f77a07 --- /dev/null +++ b/decorest/decorator_utils.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2018-2021 Bartosz Kryza +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Decorator utility functions.""" + +import numbers +import typing + +from requests.structures import CaseInsensitiveDict + +from .types import HttpMethod +from .utils import merge_dicts + +DECOR_KEY = '__decorest__' + +DECOR_LIST = [ + 'header', 'query', 'form', 'multipart', 'on', 'accept', 'content', + 'timeout', 'stream', 'body', 'endpoint' +] + + +def set_decor(t: typing.Any, name: str, value: typing.Any) -> None: + """Decorate a function or class by storing the value under specific key.""" + if hasattr(t, '__wrapped__') and hasattr(t.__wrapped__, DECOR_KEY): + setattr(t, DECOR_KEY, t.__wrapped__.__decorest__) + + if not hasattr(t, DECOR_KEY): + setattr(t, DECOR_KEY, {}) + + d = getattr(t, DECOR_KEY) + + if isinstance(value, CaseInsensitiveDict): + if not d.get(name): + d[name] = CaseInsensitiveDict() + d[name] = merge_dicts(d[name], value) + elif isinstance(value, dict): + if not d.get(name): + d[name] = {} + d[name] = merge_dicts(d[name], value) + elif isinstance(value, list): + if not d.get(name): + d[name] = [] + d[name].extend(value) + else: + d[name] = value + + +def get_decor(t: typing.Any, name: str) -> typing.Optional[typing.Any]: + """ + Retrieve a named decorator value from class or function. + + Args: + t (type): Decorated type (can be class or function) + name (str): Name of the key + + Returns: + object: any value assigned to the name key + + """ + if hasattr(t, DECOR_KEY) and getattr(t, DECOR_KEY).get(name): + return getattr(t, DECOR_KEY)[name] + + return None + + +def get_method_decor(t: typing.Any) -> HttpMethod: + """Return http method decor value.""" + return typing.cast(HttpMethod, get_decor(t, 'http_method')) + + +def get_header_decor(t: typing.Any) -> typing.Optional[typing.Dict[str, str]]: + """Return header decor values.""" + return typing.cast(typing.Optional[typing.Dict[str, str]], + get_decor(t, 'header')) + + +def get_query_decor(t: typing.Any) -> typing.Optional[typing.Dict[str, str]]: + """Return query decor values.""" + return typing.cast(typing.Optional[typing.Dict[str, str]], + get_decor(t, 'query')) + + +def get_form_decor(t: typing.Any) -> typing.Optional[typing.Dict[str, str]]: + """Return form decor values.""" + return typing.cast(typing.Optional[typing.Dict[str, str]], + get_decor(t, 'form')) + + +def get_multipart_decor(t: typing.Any) \ + -> typing.Optional[typing.Dict[str, str]]: + """Return multipart decor values.""" + return typing.cast(typing.Optional[typing.Dict[str, str]], + get_decor(t, 'multipart')) + + +def get_on_decor(t: typing.Any) \ + -> typing.Optional[typing.Dict[int, typing.Any]]: + """Return on decor values.""" + return typing.cast(typing.Optional[typing.Dict[int, typing.Any]], + get_decor(t, 'on')) + + +def get_accept_decor(t: typing.Any) -> typing.Optional[str]: + """Return accept decor value.""" + return typing.cast(typing.Optional[str], + get_decor(t, 'accept')) + + +def get_content_decor(t: typing.Any) -> typing.Optional[str]: + """Return content-type decor value.""" + return typing.cast(typing.Optional[str], + get_decor(t, 'content')) + + +def get_timeout_decor(t: typing.Any) -> typing.Optional[numbers.Real]: + """Return timeout decor value.""" + return typing.cast(typing.Optional[numbers.Real], + get_decor(t, 'timeout')) + + +def get_stream_decor(t: typing.Any) -> bool: + """Return stream decor value.""" + return typing.cast(bool, get_decor(t, 'stream')) + + +def get_body_decor(t: typing.Any) -> typing.Optional[typing.Any]: + """Return body decor value.""" + return get_decor(t, 'body') + + +def get_endpoint_decor(t: typing.Any) -> typing.Optional[str]: + """Return endpoint decor value.""" + return typing.cast(typing.Optional[str], get_decor(t, 'endpoint')) diff --git a/decorest/decorators.py b/decorest/decorators.py index 701cf20..1c3a3ff 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -21,141 +21,18 @@ """ import inspect -import json -import logging as LOG import numbers import pprint import typing from operator import methodcaller -import requests from requests.structures import CaseInsensitiveDict from . import types +from .decorator_utils import set_decor from .errors import HTTPErrorWrapper -from .types import ArgsDict, HttpMethod, HttpStatus, TDecor -from .utils import dict_from_args, merge_dicts, render_path - -DECOR_KEY = '__decorest__' - -DECOR_LIST = [ - 'header', 'query', 'form', 'multipart', 'on', 'accept', 'content', - 'timeout', 'stream', 'body', 'endpoint' -] - - -def set_decor(t: typing.Any, name: str, value: typing.Any) -> None: - """Decorate a function or class by storing the value under specific key.""" - if hasattr(t, '__wrapped__') and hasattr(t.__wrapped__, DECOR_KEY): - setattr(t, DECOR_KEY, t.__wrapped__.__decorest__) - - if not hasattr(t, DECOR_KEY): - setattr(t, DECOR_KEY, {}) - - d = getattr(t, DECOR_KEY) - - if isinstance(value, CaseInsensitiveDict): - if not d.get(name): - d[name] = CaseInsensitiveDict() - d[name] = merge_dicts(d[name], value) - elif isinstance(value, dict): - if not d.get(name): - d[name] = {} - d[name] = merge_dicts(d[name], value) - elif isinstance(value, list): - if not d.get(name): - d[name] = [] - d[name].extend(value) - else: - d[name] = value - - -def get_decor(t: typing.Any, name: str) -> typing.Optional[typing.Any]: - """ - Retrieve a named decorator value from class or function. - - Args: - t (type): Decorated type (can be class or function) - name (str): Name of the key - - Returns: - object: any value assigned to the name key - - """ - if hasattr(t, DECOR_KEY) and getattr(t, DECOR_KEY).get(name): - return getattr(t, DECOR_KEY)[name] - - return None - - -def get_method_decor(t: typing.Any) -> HttpMethod: - """Return http method decor value.""" - return typing.cast(HttpMethod, get_decor(t, 'http_method')) - - -def get_header_decor(t: typing.Any) -> typing.Optional[typing.Dict[str, str]]: - """Return header decor values.""" - return typing.cast(typing.Optional[typing.Dict[str, str]], - get_decor(t, 'header')) - - -def get_query_decor(t: typing.Any) -> typing.Optional[typing.Dict[str, str]]: - """Return query decor values.""" - return typing.cast(typing.Optional[typing.Dict[str, str]], - get_decor(t, 'query')) - - -def get_form_decor(t: typing.Any) -> typing.Optional[typing.Dict[str, str]]: - """Return form decor values.""" - return typing.cast(typing.Optional[typing.Dict[str, str]], - get_decor(t, 'form')) - - -def get_multipart_decor(t: typing.Any) \ - -> typing.Optional[typing.Dict[str, str]]: - """Return multipart decor values.""" - return typing.cast(typing.Optional[typing.Dict[str, str]], - get_decor(t, 'multipart')) - - -def get_on_decor(t: typing.Any) \ - -> typing.Optional[typing.Dict[int, typing.Any]]: - """Return on decor values.""" - return typing.cast(typing.Optional[typing.Dict[int, typing.Any]], - get_decor(t, 'on')) - - -def get_accept_decor(t: typing.Any) -> typing.Optional[str]: - """Return accept decor value.""" - return typing.cast(typing.Optional[str], - get_decor(t, 'accept')) - - -def get_content_decor(t: typing.Any) -> typing.Optional[str]: - """Return content-type decor value.""" - return typing.cast(typing.Optional[str], - get_decor(t, 'content')) - - -def get_timeout_decor(t: typing.Any) -> typing.Optional[numbers.Real]: - """Return timeout decor value.""" - return typing.cast(typing.Optional[numbers.Real], - get_decor(t, 'timeout')) - - -def get_stream_decor(t: typing.Any) -> bool: - """Return stream decor value.""" - return typing.cast(bool, get_decor(t, 'stream')) - - -def get_body_decor(t: typing.Any) -> typing.Optional[typing.Any]: - """Return body decor value.""" - return get_decor(t, 'body') - - -def get_endpoint_decor(t: typing.Any) -> typing.Optional[str]: - """Return endpoint decor value.""" - return typing.cast(typing.Optional[str], get_decor(t, 'endpoint')) +from .request import HttpRequest +from .types import HttpMethod, HttpStatus, TDecor def on(status: typing.Union[types.ellipsis, int], @@ -318,285 +195,6 @@ def stream(t: TDecor) -> TDecor: return t -# return execution_context, on_handlers, rest_client -class HttpRequest: - """ - HTTP request wrapper. - - Class representing an HTTP request created from decorators and arguments. - """ - http_method: str - is_multipart_request: bool - is_stream: bool - req: str - kwargs: ArgsDict - on_handlers: typing.Mapping[int, typing.Callable[..., typing.Any]] - session: str - execution_context: typing.Any - rest_client: 'RestClient' # noqa - - def __init__(self, func, path_template, args, kwargs): - """ - Construct HttpRequest instance. - - Args: - func - decorated function - path_template - template for creating the request path - args - arguments - kwargs - named arguments - """ - self.http_method = get_method_decor(func) - self.path_template = path_template - self.kwargs = kwargs - - if self.http_method not in (HttpMethod.GET, HttpMethod.POST, - HttpMethod.PUT, HttpMethod.PATCH, - HttpMethod.DELETE, HttpMethod.HEAD, - HttpMethod.OPTIONS): - raise ValueError( - 'Unsupported HTTP method: {method}'.format( - method=self.http_method)) - - self.rest_client = args[0] - args_dict = dict_from_args(func, *args) - req_path = render_path(self.path_template, args_dict) - self.session = None - if '__session' in self.kwargs: - self.session = self.kwargs['__session'] - del self.kwargs['__session'] - # Merge query parameters from common values for all method - # invocations with arguments provided in the method - # arguments - query_parameters = self.__merge_args(args_dict, func, 'query') - form_parameters = self.__merge_args(args_dict, func, 'form') - multipart_parameters = self.__merge_args(args_dict, func, 'multipart') - header_parameters = merge_dicts( - get_header_decor(self.rest_client.__class__), - self.__merge_args(args_dict, func, 'header')) - # Merge header parameters with default values, treat header - # decorators with 2 params as default values only if they - # don't match the function argument names - func_header_decors = get_header_decor(func) - if func_header_decors: - for key in func_header_decors.keys(): - if not func_header_decors[key] in args_dict: - header_parameters[key] = func_header_decors[key] - # Get body content from positional arguments if one is specified - # using @body decorator - body_parameter = get_body_decor(func) - body_content = None - if body_parameter: - body_content = args_dict.get(body_parameter[0]) - # Serialize body content first if serialization handler - # was provided - if body_content and body_parameter[1]: - body_content = body_parameter[1](body_content) - # Get authentication method for this call - auth = self.rest_client._auth() - # Get status handlers - self.on_handlers = merge_dicts(get_on_decor(self.rest_client.__class__), - get_on_decor(func)) - # Get timeout - request_timeout = get_timeout_decor(self.rest_client.__class__) - if get_timeout_decor(func): - request_timeout = get_timeout_decor(func) - # Check if stream is requested for this call - self.is_stream = get_stream_decor(func) - if self.is_stream is None: - self.is_stream = get_stream_decor(self.rest_client.__class__) - # - # If the kwargs contains any decorest decorators that should - # be overloaded for this call, extract them. - # - # Pass the rest of kwargs to requests calls - # - if self.kwargs: - for decor in DECOR_LIST: - if decor in self.kwargs: - if decor == 'header': - self.__validate_decor(decor, self.kwargs, dict) - header_parameters = merge_dicts( - header_parameters, self.kwargs['header']) - del self.kwargs['header'] - elif decor == 'query': - self.__validate_decor(decor, self.kwargs, dict) - query_parameters = merge_dicts(query_parameters, - self.kwargs['query']) - del self.kwargs['query'] - elif decor == 'form': - self.__validate_decor(decor, self.kwargs, dict) - form_parameters = merge_dicts(form_parameters, - self.kwargs['form']) - del self.kwargs['form'] - elif decor == 'multipart': - self.__validate_decor(decor, self.kwargs, dict) - multipart_parameters = merge_dicts( - multipart_parameters, self.kwargs['multipart']) - del self.kwargs['multipart'] - elif decor == 'on': - self.__validate_decor(decor, self.kwargs, dict) - self.on_handlers = merge_dicts(self.on_handlers, - self.kwargs['on']) - del self.kwargs['on'] - elif decor == 'accept': - self.__validate_decor(decor, self.kwargs, str) - header_parameters['accept'] = self.kwargs['accept'] - del self.kwargs['accept'] - elif decor == 'content': - self.__validate_decor(decor, self.kwargs, str) - header_parameters['content-type'] \ - = self.kwargs['content'] - del self.kwargs['content'] - elif decor == 'timeout': - self.__validate_decor(decor, self.kwargs, - numbers.Number) - request_timeout = self.kwargs['timeout'] - del self.kwargs['timeout'] - elif decor == 'stream': - self.__validate_decor(decor, self.kwargs, bool) - self.is_stream = self.kwargs['stream'] - del self.kwargs['stream'] - elif decor == 'body': - body_content = self.kwargs['body'] - del self.kwargs['body'] - else: - pass - # Build request from endpoint and query params - self.req = self.rest_client.build_request(req_path.split('/')) - - # Handle multipart parameters, either from decorators - # or ones passed directly through kwargs - if multipart_parameters: - self.is_multipart_request = True - self.kwargs['files'] = multipart_parameters - elif self.rest_client._backend() == 'requests': - from requests_toolbelt.multipart.encoder import MultipartEncoder - self.is_multipart_request = \ - 'data' in self.kwargs and \ - not isinstance(self.kwargs['data'], MultipartEncoder) - else: - self.is_multipart_request = 'files' in self.kwargs - - # Assume default content type if not multipart - if ('content-type' not in header_parameters) \ - and not self.is_multipart_request: - header_parameters['content-type'] = 'application/json' - - # Assume default accept - if 'accept' not in header_parameters: - header_parameters['accept'] = 'application/json' - - LOG.debug('Request: {method} {request}'.format(method=self.http_method, - request=self.req)) - if auth: - self.kwargs['auth'] = auth - if request_timeout: - self.kwargs['timeout'] = request_timeout - if body_content: - if header_parameters.get('content-type') == 'application/json': - if isinstance(body_content, dict): - body_content = json.dumps(body_content) - - if self.rest_client._backend() == 'httpx': - if isinstance(body_content, dict): - self.kwargs['data'] = body_content - else: - self.kwargs['content'] = body_content - else: - kwargs['data'] = body_content - if query_parameters: - self.kwargs['params'] = query_parameters - if form_parameters: - # If form parameters were passed, override the content-type - header_parameters['content-type'] \ - = 'application/x-www-form-urlencoded' - self.kwargs['data'] = form_parameters - if self.is_stream: - self.kwargs['stream'] = self.is_stream - if header_parameters: - self.kwargs['headers'] = dict(header_parameters.items()) - - # If '__session' was passed in the kwargs, execute this request - # using the session context, otherwise execute directly via the - # requests or httpx module - if self.session: - self.execution_context = self.session - else: - if self.rest_client._backend() == 'requests': - self.execution_context = requests - else: - import httpx - self.execution_context = httpx - - def __validate_decor(self, decor: str, kwargs: ArgsDict, - cls: typing.Type[typing.Any]) -> None: - """ - Ensure kwargs contain decor with specific type. - - Args: - decor(str): Name of the decorator - kwargs(dict): Named arguments passed to API call - cls(class): Expected type of decorator parameter - """ - if not isinstance(kwargs[decor], cls): - raise TypeError( - "{} value must be an instance of {}".format( - decor, cls.__name__)) - - def __merge_args(self, args_dict: ArgsDict, - func: typing.Callable[..., typing.Any], decor: str) \ - -> ArgsDict: - """ - Match named arguments from method call. - - Args: - args_dict (dict): Function arguments dictionary - func (type): Decorated function - decor (str): Name of specific decorator (e.g. 'query') - - Returns: - object: any value assigned to the name key - """ - args_decor = get_decor(func, decor) - parameters = {} - if args_decor: - for arg, param in args_decor.items(): - if args_dict.get(arg): - parameters[param] = args_dict[arg] - return parameters - - def handle(self, result): - """Handle result response.""" - if self.on_handlers and result.status_code in self.on_handlers: - # Use a registered handler for the returned status code - return self.on_handlers[result.status_code](result) - elif self.on_handlers and HttpStatus.ANY in self.on_handlers: - # If a catch all status handler is provided - use it - return self.on_handlers[HttpStatus.ANY](result) - else: - # If stream option was passed and no content handler - # was defined, return response - if self.is_stream: - return result - - # Default response handler - try: - result.raise_for_status() - except Exception as e: - raise HTTPErrorWrapper(typing.cast(types.HTTPErrors, e)) - - if result.text: - content_type = result.headers.get('content-type') - if content_type == 'application/json': - return result.json() - elif content_type == 'application/octet-stream': - return result.content - else: - return result.text - - return None - - class HttpMethodDecorator: """Abstract decorator for HTTP method decorators.""" diff --git a/decorest/request.py b/decorest/request.py new file mode 100644 index 0000000..3ef6aa4 --- /dev/null +++ b/decorest/request.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2018-2021 Bartosz Kryza +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""HTTP request wrapper.""" + +import json +import logging as LOG +import numbers +import typing + +import requests + +from .client import RestClient +from .decorator_utils import DECOR_LIST, get_body_decor, get_decor, \ + get_header_decor, get_method_decor, get_on_decor, \ + get_stream_decor, get_timeout_decor +from .errors import HTTPErrorWrapper +from .types import ArgsDict, HTTPErrors, HttpMethod, HttpStatus +from .utils import dict_from_args, merge_dicts, render_path + + +class HttpRequest: + """ + HTTP request wrapper. + + Class representing an HTTP request created from decorators and arguments. + """ + http_method: HttpMethod + is_multipart_request: bool + is_stream: bool + req: str + kwargs: ArgsDict + on_handlers: typing.Mapping[int, typing.Callable[..., typing.Any]] + session: typing.Optional[str] + execution_context: typing.Any + rest_client: RestClient + + def __init__(self, func: typing.Callable[..., typing.Any], + path_template: str, + args: typing.Tuple[typing.Any, ...], + kwargs: ArgsDict): + """ + Construct HttpRequest instance. + + Args: + func - decorated function + path_template - template for creating the request path + args - arguments + kwargs - named arguments + """ + self.http_method = get_method_decor(func) + self.path_template = path_template + self.kwargs = kwargs + + if self.http_method not in (HttpMethod.GET, HttpMethod.POST, + HttpMethod.PUT, HttpMethod.PATCH, + HttpMethod.DELETE, HttpMethod.HEAD, + HttpMethod.OPTIONS): + raise ValueError( + 'Unsupported HTTP method: {method}'.format( + method=self.http_method)) + + self.rest_client = args[0] + args_dict = dict_from_args(func, *args) + req_path = render_path(self.path_template, args_dict) + self.session = None + if '__session' in self.kwargs: + self.session = self.kwargs['__session'] + del self.kwargs['__session'] + + # Merge query parameters from common values for all method + # invocations with arguments provided in the method + # arguments + query_parameters = self.__merge_args(args_dict, func, 'query') + form_parameters = self.__merge_args(args_dict, func, 'form') + multipart_parameters = self.__merge_args(args_dict, func, 'multipart') + header_parameters = merge_dicts( + get_header_decor(self.rest_client.__class__), + self.__merge_args(args_dict, func, 'header')) + # Merge header parameters with default values, treat header + # decorators with 2 params as default values only if they + # don't match the function argument names + func_header_decors = get_header_decor(func) + if func_header_decors: + for key in func_header_decors.keys(): + if not func_header_decors[key] in args_dict: + header_parameters[key] = func_header_decors[key] + # Get body content from positional arguments if one is specified + # using @body decorator + body_parameter = get_body_decor(func) + body_content = None + if body_parameter: + body_content = args_dict.get(body_parameter[0]) + # Serialize body content first if serialization handler + # was provided + if body_content and body_parameter[1]: + body_content = body_parameter[1](body_content) + # Get authentication method for this call + auth = self.rest_client._auth() + # Get status handlers + self.on_handlers = merge_dicts(get_on_decor(self.rest_client.__class__), + get_on_decor(func)) + # Get timeout + request_timeout = get_timeout_decor(self.rest_client.__class__) + if get_timeout_decor(func): + request_timeout = get_timeout_decor(func) + # Check if stream is requested for this call + self.is_stream = get_stream_decor(func) + if self.is_stream is None: + self.is_stream = get_stream_decor(self.rest_client.__class__) + # + # If the kwargs contains any decorest decorators that should + # be overloaded for this call, extract them. + # + # Pass the rest of kwargs to requests calls + # + if self.kwargs: + for decor in DECOR_LIST: + if decor in self.kwargs: + if decor == 'header': + self.__validate_decor(decor, self.kwargs, dict) + header_parameters = merge_dicts( + header_parameters, self.kwargs['header']) + del self.kwargs['header'] + elif decor == 'query': + self.__validate_decor(decor, self.kwargs, dict) + query_parameters = merge_dicts(query_parameters, + self.kwargs['query']) + del self.kwargs['query'] + elif decor == 'form': + self.__validate_decor(decor, self.kwargs, dict) + form_parameters = merge_dicts(form_parameters, + self.kwargs['form']) + del self.kwargs['form'] + elif decor == 'multipart': + self.__validate_decor(decor, self.kwargs, dict) + multipart_parameters = merge_dicts( + multipart_parameters, self.kwargs['multipart']) + del self.kwargs['multipart'] + elif decor == 'on': + self.__validate_decor(decor, self.kwargs, dict) + self.on_handlers = merge_dicts(self.on_handlers, + self.kwargs['on']) + del self.kwargs['on'] + elif decor == 'accept': + self.__validate_decor(decor, self.kwargs, str) + header_parameters['accept'] = self.kwargs['accept'] + del self.kwargs['accept'] + elif decor == 'content': + self.__validate_decor(decor, self.kwargs, str) + header_parameters['content-type'] \ + = self.kwargs['content'] + del self.kwargs['content'] + elif decor == 'timeout': + self.__validate_decor(decor, self.kwargs, + numbers.Number) + request_timeout = self.kwargs['timeout'] + del self.kwargs['timeout'] + elif decor == 'stream': + self.__validate_decor(decor, self.kwargs, bool) + self.is_stream = self.kwargs['stream'] + del self.kwargs['stream'] + elif decor == 'body': + body_content = self.kwargs['body'] + del self.kwargs['body'] + else: + pass + # Build request from endpoint and query params + self.req = self.rest_client.build_request(req_path.split('/')) + + # Handle multipart parameters, either from decorators + # or ones passed directly through kwargs + if multipart_parameters: + self.is_multipart_request = True + self.kwargs['files'] = multipart_parameters + elif self.rest_client._backend() == 'requests': + from requests_toolbelt.multipart.encoder import MultipartEncoder + self.is_multipart_request = \ + 'data' in self.kwargs and \ + not isinstance(self.kwargs['data'], MultipartEncoder) + else: + self.is_multipart_request = 'files' in self.kwargs + + # Assume default content type if not multipart + if ('content-type' not in header_parameters) \ + and not self.is_multipart_request: + header_parameters['content-type'] = 'application/json' + + # Assume default accept + if 'accept' not in header_parameters: + header_parameters['accept'] = 'application/json' + + LOG.debug('Request: {method} {request}'.format(method=self.http_method, + request=self.req)) + if auth: + self.kwargs['auth'] = auth + if request_timeout: + self.kwargs['timeout'] = request_timeout + if body_content: + if header_parameters.get('content-type') == 'application/json': + if isinstance(body_content, dict): + body_content = json.dumps(body_content) + + if self.rest_client._backend() == 'httpx': + if isinstance(body_content, dict): + self.kwargs['data'] = body_content + else: + self.kwargs['content'] = body_content + else: + kwargs['data'] = body_content + if query_parameters: + self.kwargs['params'] = query_parameters + if form_parameters: + # If form parameters were passed, override the content-type + header_parameters['content-type'] \ + = 'application/x-www-form-urlencoded' + self.kwargs['data'] = form_parameters + if self.is_stream: + self.kwargs['stream'] = self.is_stream + if header_parameters: + self.kwargs['headers'] = dict(header_parameters.items()) + + # If '__session' was passed in the kwargs, execute this request + # using the session context, otherwise execute directly via the + # requests or httpx module + if self.session: + self.execution_context = self.session + else: + if self.rest_client._backend() == 'requests': + self.execution_context = requests + else: + import httpx + self.execution_context = httpx + + def __validate_decor(self, decor: str, kwargs: ArgsDict, + cls: typing.Type[typing.Any]) -> None: + """ + Ensure kwargs contain decor with specific type. + + Args: + decor(str): Name of the decorator + kwargs(dict): Named arguments passed to API call + cls(class): Expected type of decorator parameter + """ + if not isinstance(kwargs[decor], cls): + raise TypeError( + "{} value must be an instance of {}".format( + decor, cls.__name__)) + + def __merge_args(self, args_dict: ArgsDict, + func: typing.Callable[..., typing.Any], decor: str) \ + -> ArgsDict: + """ + Match named arguments from method call. + + Args: + args_dict (dict): Function arguments dictionary + func (type): Decorated function + decor (str): Name of specific decorator (e.g. 'query') + + Returns: + object: any value assigned to the name key + """ + args_decor = get_decor(func, decor) + parameters = {} + if args_decor: + for arg, param in args_decor.items(): + if args_dict.get(arg): + parameters[param] = args_dict[arg] + return parameters + + def handle(self, result: typing.Any) -> typing.Any: + """Handle result response.""" + if self.on_handlers and result.status_code in self.on_handlers: + # Use a registered handler for the returned status code + return self.on_handlers[result.status_code](result) + elif self.on_handlers and HttpStatus.ANY in self.on_handlers: + # If a catch all status handler is provided - use it + return self.on_handlers[HttpStatus.ANY](result) + else: + # If stream option was passed and no content handler + # was defined, return response + if self.is_stream: + return result + + # Default response handler + try: + result.raise_for_status() + except Exception as e: + raise HTTPErrorWrapper(typing.cast(HTTPErrors, e)) + + if result.text: + content_type = result.headers.get('content-type') + if content_type == 'application/json': + return result.json() + elif content_type == 'application/octet-stream': + return result.content + else: + return result.text + + return None diff --git a/tests/decorators_tests.py b/tests/decorators_tests.py index 3731b52..de397fd 100644 --- a/tests/decorators_tests.py +++ b/tests/decorators_tests.py @@ -21,7 +21,8 @@ from decorest import RestClient, HttpMethod from decorest import GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS from decorest import accept, content, endpoint, form, header, query, stream -from decorest.decorators import get_decor, get_header_decor, get_endpoint_decor, get_form_decor, get_query_decor, \ +from decorest.decorator_utils import get_decor, get_header_decor, \ + get_endpoint_decor, get_form_decor, get_query_decor, \ get_stream_decor, get_on_decor, get_method_decor From ed5c9d31f3bfa5d2c44234735420b5a91fdb5165 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Fri, 24 Dec 2021 14:28:47 +0100 Subject: [PATCH 13/48] Refactored session wrappers to a separate file --- decorest/client.py | 89 +----------------------------------- decorest/session.py | 108 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 87 deletions(-) create mode 100644 decorest/session.py diff --git a/decorest/client.py b/decorest/client.py index a38d388..055ccf5 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -18,101 +18,16 @@ This module contains also some enums for HTTP protocol. """ -import asyncio import logging as LOG import typing import urllib.parse from .decorator_utils import get_decor -from .types import AuthTypes, Backends, SessionTypes +from .session import RestClientAsyncSession, RestClientSession +from .types import AuthTypes, Backends from .utils import normalize_url -class RestClientSession: - """Wrap a `requests` session for specific API client.""" - def __init__(self, client: 'RestClient') -> None: - """Initialize the session instance with a specific API client.""" - self.__client: 'RestClient' = client - - # Create a session of type specific for given backend - if client._backend() == 'requests': - import requests - self.__session: SessionTypes = requests.Session() - else: - import httpx - self.__session = httpx.Client() - - if self.__client.auth is not None: - self.__session.auth = self.__client.auth - - def __enter__(self) -> 'RestClientSession': - """Context manager initialization.""" - return self - - def __exit__(self, *args: typing.Any) -> None: - """Context manager destruction.""" - self.__session.close() - - def __getattr__(self, name: str) -> typing.Any: - """Forward any method invocation to actual client with session.""" - if name == '_requests_session': - return self.__session - - if name == '_client': - return self.__client - - if name == '_close': - return self.__session.close - - def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: - kwargs['__session'] = self.__session - return getattr(self.__client, name)(*args, **kwargs) - - return invoker - - -class RestClientAsyncSession: - """Wrap a `requests` session for specific API client.""" - def __init__(self, client: 'RestClient') -> None: - """Initialize the session instance with a specific API client.""" - self.__client: 'RestClient' = client - - # Create a session of type specific for given backend - import httpx - self.__session = httpx.AsyncClient() - - if self.__client.auth is not None: - self.__session.auth = self.__client.auth - - async def __aenter__(self) -> 'RestClientAsyncSession': - """Context manager initialization.""" - await self.__session.__aenter__() - return self - - async def __aexit__(self, *args: typing.Any) -> None: - """Context manager destruction.""" - await self.__session.__aexit__(*args) - - def __getattr__(self, name: str) -> typing.Any: - """Forward any method invocation to actual client with session.""" - if name == '_requests_session': - return self.__session - - if name == '_client': - return self.__client - - if name == '_close': - return self.__session.aclose - - async def invoker(*args: typing.Any, - **kwargs: typing.Any) -> typing.Any: - kwargs['__session'] = self.__session - assert asyncio.iscoroutinefunction(getattr(self.__client, name)) - return await getattr(self.__client, name)(*args, **kwargs) - - return invoker - - class RestClient: """Base class for decorest REST clients.""" def __init__(self, diff --git a/decorest/session.py b/decorest/session.py new file mode 100644 index 0000000..324ca7b --- /dev/null +++ b/decorest/session.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2018-2021 Bartosz Kryza +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Generic wrappers around Http sessions.""" + +import asyncio +import typing + +if typing.TYPE_CHECKING: + from .client import RestClient + + +class RestClientSession: + """Wrap a `requests` session for specific API client.""" + def __init__(self, client: 'RestClient') -> None: + """Initialize the session instance with a specific API client.""" + self.__client: 'RestClient' = client + + # Create a session of type specific for given backend + if client._backend() == 'requests': + import requests + from decorest.types import SessionTypes + self.__session: SessionTypes = requests.Session() + else: + import httpx + self.__session = httpx.Client() + + if self.__client.auth is not None: + self.__session.auth = self.__client.auth + + def __enter__(self) -> 'RestClientSession': + """Context manager initialization.""" + return self + + def __exit__(self, *args: typing.Any) -> None: + """Context manager destruction.""" + self.__session.close() + + def __getattr__(self, name: str) -> typing.Any: + """Forward any method invocation to actual client with session.""" + if name == '_requests_session': + return self.__session + + if name == '_client': + return self.__client + + if name == '_close': + return self.__session.close + + def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + kwargs['__session'] = self.__session + return getattr(self.__client, name)(*args, **kwargs) + + return invoker + + +class RestClientAsyncSession: + """Wrap a `requests` session for specific API client.""" + def __init__(self, client: 'RestClient') -> None: + """Initialize the session instance with a specific API client.""" + self.__client: 'RestClient' = client + + # Create a session of type specific for given backend + import httpx + self.__session = httpx.AsyncClient() + + if self.__client.auth is not None: + self.__session.auth = self.__client.auth + + async def __aenter__(self) -> 'RestClientAsyncSession': + """Context manager initialization.""" + await self.__session.__aenter__() + return self + + async def __aexit__(self, *args: typing.Any) -> None: + """Context manager destruction.""" + await self.__session.__aexit__(*args) + + def __getattr__(self, name: str) -> typing.Any: + """Forward any method invocation to actual client with session.""" + if name == '_requests_session': + return self.__session + + if name == '_client': + return self.__client + + if name == '_close': + return self.__session.aclose + + async def invoker(*args: typing.Any, + **kwargs: typing.Any) -> typing.Any: + kwargs['__session'] = self.__session + assert asyncio.iscoroutinefunction(getattr(self.__client, name)) + return await getattr(self.__client, name)(*args, **kwargs) + + return invoker From fbc6c1bd9379ac127b5533520f14b77c03215c49 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Fri, 24 Dec 2021 21:30:25 +0100 Subject: [PATCH 14/48] Added multi put async test --- tests/httpbin_async_test.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index 4f469ea..eb78133 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -13,7 +13,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import asyncio import pprint +from operator import methodcaller import pytest import time @@ -211,6 +213,25 @@ async def test_put(client): assert res["json"] == data +@pytest.mark.asyncio +async def test_multi_put(client): + """ + """ + request_count = 100 + async with client._async_session() as s: + reqs = [asyncio.ensure_future(s.put({i: str(i)}, + content="application/json")) + for i in range(0, request_count)] + + reqs_result = await asyncio.gather(*reqs) + + keys = [] + for res in reqs_result: + keys.append(int(list(res["json"].keys())[0])) + + assert keys == list(range(0, request_count)) + + @pytest.mark.asyncio async def test_delete(client): """ From 0d2d1552ca774cfb15ce727760478895b5446f1b Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 26 Dec 2021 16:45:00 +0100 Subject: [PATCH 15/48] Fixed handling of multiple header decorators --- decorest/decorator_utils.py | 22 ++++++++++++++++++-- decorest/decorators.py | 30 +++++++++------------------ decorest/request.py | 32 +++++++++++++++++++++-------- decorest/types.py | 1 + decorest/utils.py | 31 ++++++++++++++++++++++++++++ examples/httpbin/httpbin_client.py | 28 +++++++++++++++++++++++-- tests/httpbin_test.py | 33 +++++++++++++++++++++++++++--- 7 files changed, 142 insertions(+), 35 deletions(-) diff --git a/decorest/decorator_utils.py b/decorest/decorator_utils.py index 0f77a07..26b9f4c 100644 --- a/decorest/decorator_utils.py +++ b/decorest/decorator_utils.py @@ -20,8 +20,8 @@ from requests.structures import CaseInsensitiveDict -from .types import HttpMethod -from .utils import merge_dicts +from .types import HeaderDict, HttpMethod +from .utils import merge_dicts, merge_header_dicts DECOR_KEY = '__decorest__' @@ -57,6 +57,24 @@ def set_decor(t: typing.Any, name: str, value: typing.Any) -> None: d[name] = value +def set_header_decor(t: typing.Any, + value: HeaderDict) -> None: + """Decorate a function or class with header decorator.""" + if hasattr(t, '__wrapped__') and hasattr(t.__wrapped__, DECOR_KEY): + setattr(t, DECOR_KEY, t.__wrapped__.__decorest__) + + if not hasattr(t, DECOR_KEY): + setattr(t, DECOR_KEY, {}) + + d = getattr(t, DECOR_KEY) + name = 'header' + + if not d.get(name): + d[name] = CaseInsensitiveDict() + + d[name] = merge_header_dicts(d[name], value) + + def get_decor(t: typing.Any, name: str) -> typing.Optional[typing.Any]: """ Retrieve a named decorator value from class or function. diff --git a/decorest/decorators.py b/decorest/decorators.py index 1c3a3ff..98445e2 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -22,14 +22,13 @@ import inspect import numbers -import pprint import typing from operator import methodcaller from requests.structures import CaseInsensitiveDict from . import types -from .decorator_utils import set_decor +from .decorator_utils import set_decor, set_header_decor from .errors import HTTPErrorWrapper from .request import HttpRequest from .types import HttpMethod, HttpStatus, TDecor @@ -66,9 +65,8 @@ def query_decorator(t: TDecor) -> TDecor: if inspect.isclass(t): raise TypeError("@query decorator can only be " "applied to methods.") - if not value_: - value_ = name - set_decor(t, 'query', {name: value_}) + + set_decor(t, 'query', {name: value_ or name}) return t return query_decorator @@ -83,9 +81,8 @@ def form_decorator(t: TDecor) -> TDecor: if inspect.isclass(t): raise TypeError("@form decorator can only be " "applied to methods.") - if not value_: - value_ = name - set_decor(t, 'form', {name: value_}) + + set_decor(t, 'form', {name: value_ or name}) return t return form_decorator @@ -100,9 +97,8 @@ def multipart_decorator(t: TDecor) -> TDecor: if inspect.isclass(t): raise TypeError("@multipart decorator can only be " "applied to methods.") - if not value_: - value_ = name - set_decor(t, 'multipart', {name: value_}) + + set_decor(t, 'multipart', {name: value_ or name}) return t return multipart_decorator @@ -113,10 +109,7 @@ def header(name: str, value: typing.Optional[str] = None) \ """Header class and method decorator.""" def header_decorator(t: TDecor) -> TDecor: - value_ = value - if not value_: - value_ = name - set_decor(t, 'header', CaseInsensitiveDict({name: value_})) + set_header_decor(t, CaseInsensitiveDict({name: value or name})) return t return header_decorator @@ -136,8 +129,7 @@ def content(value: str) -> typing.Callable[[TDecor], TDecor]: """Content-type header class and method decorator.""" def content_decorator(t: TDecor) -> TDecor: - set_decor(t, 'header', - CaseInsensitiveDict({'Content-Type': value})) + set_header_decor(t, CaseInsensitiveDict({'Content-Type': value})) return t return content_decorator @@ -147,8 +139,7 @@ def accept(value: str) -> typing.Callable[[TDecor], TDecor]: """Accept header class and method decorator.""" def accept_decorator(t: TDecor) -> TDecor: - set_decor(t, 'header', - CaseInsensitiveDict({'Accept': value})) + set_header_decor(t, CaseInsensitiveDict({'Accept': value})) return t return accept_decorator @@ -209,7 +200,6 @@ async def call_async(self, func: typing.Callable[..., typing.Any], http_request = HttpRequest(func, self.path_template, args, kwargs) try: - pprint.pprint(http_request) if http_request.http_method == HttpMethod.GET \ and http_request.is_stream: del kwargs['stream'] diff --git a/decorest/request.py b/decorest/request.py index 3ef6aa4..cb00497 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -21,6 +21,7 @@ import typing import requests +from requests.structures import CaseInsensitiveDict from .client import RestClient from .decorator_utils import DECOR_LIST, get_body_decor, get_decor, \ @@ -86,17 +87,24 @@ def __init__(self, func: typing.Callable[..., typing.Any], query_parameters = self.__merge_args(args_dict, func, 'query') form_parameters = self.__merge_args(args_dict, func, 'form') multipart_parameters = self.__merge_args(args_dict, func, 'multipart') - header_parameters = merge_dicts( + header_parameters = CaseInsensitiveDict(merge_dicts( get_header_decor(self.rest_client.__class__), - self.__merge_args(args_dict, func, 'header')) + self.__merge_args(args_dict, func, 'header'))) + # Merge header parameters with default values, treat header # decorators with 2 params as default values only if they # don't match the function argument names func_header_decors = get_header_decor(func) + if func_header_decors: - for key in func_header_decors.keys(): - if not func_header_decors[key] in args_dict: - header_parameters[key] = func_header_decors[key] + for key, value in func_header_decors.items(): + if key not in args_dict: + header_parameters[key] = value + + for key, value in header_parameters.items(): + if isinstance(value, list): + header_parameters[key] = ", ".join(value) + # Get body content from positional arguments if one is specified # using @body decorator body_parameter = get_body_decor(func) @@ -107,19 +115,24 @@ def __init__(self, func: typing.Callable[..., typing.Any], # was provided if body_content and body_parameter[1]: body_content = body_parameter[1](body_content) + # Get authentication method for this call auth = self.rest_client._auth() + # Get status handlers self.on_handlers = merge_dicts(get_on_decor(self.rest_client.__class__), get_on_decor(func)) + # Get timeout request_timeout = get_timeout_decor(self.rest_client.__class__) if get_timeout_decor(func): request_timeout = get_timeout_decor(func) + # Check if stream is requested for this call self.is_stream = get_stream_decor(func) if self.is_stream is None: self.is_stream = get_stream_decor(self.rest_client.__class__) + # # If the kwargs contains any decorest decorators that should # be overloaded for this call, extract them. @@ -276,9 +289,12 @@ def __merge_args(self, args_dict: ArgsDict, args_decor = get_decor(func, decor) parameters = {} if args_decor: - for arg, param in args_decor.items(): - if args_dict.get(arg): - parameters[param] = args_dict[arg] + for arg, value in args_decor.items(): + if (isinstance(value, str)) \ + and arg in args_dict.keys(): + parameters[value] = args_dict[arg] + else: + parameters[arg] = value return parameters def handle(self, result: typing.Any) -> typing.Any: diff --git a/decorest/types.py b/decorest/types.py index 1ba96a8..e2e1841 100644 --- a/decorest/types.py +++ b/decorest/types.py @@ -56,6 +56,7 @@ class HttpStatus(DIntEnum): ArgsDict = typing.Dict[str, typing.Any] Backends = typing_extensions.Literal['requests', 'httpx'] AuthTypes = typing.Union['requests.auth.AuthBase', 'httpx.Auth'] +HeaderDict = typing.Mapping[str, typing.Union[str, typing.List[str]]] SessionTypes = typing.Union['requests.Session', 'httpx.Client'] HTTPErrors = typing.Union['requests.HTTPError', 'httpx.HTTPStatusError'] diff --git a/decorest/utils.py b/decorest/utils.py index 6848fa0..cdaa636 100644 --- a/decorest/utils.py +++ b/decorest/utils.py @@ -78,6 +78,37 @@ def merge_dicts(*dict_args: typing.Any) \ return result +def merge_header_dicts(*dict_args: typing.Any) \ + -> typing.Dict[typing.Any, typing.Any]: + """ + Merge all dicts passed as arguments, skips None objects. + + Repeating key values will be appended to the existing keys. + """ + result = None + for dictionary in dict_args: + if dictionary is not None: + if result is None: + result = copy.deepcopy(dictionary) + else: + for k, v in dictionary.items(): + if k in result: + if isinstance(result[k], list): + if isinstance(v, list): + result[k].extend(v) + else: + result[k].append(v) + else: + result[k] = [result[k], v] + else: + result[k] = v + + if result is None: + result = {} + + return result + + def normalize_url(url: str) -> str: """Make sure the url is in correct form.""" result = url diff --git a/examples/httpbin/httpbin_client.py b/examples/httpbin/httpbin_client.py index 9620ce9..3ca2332 100644 --- a/examples/httpbin/httpbin_client.py +++ b/examples/httpbin/httpbin_client.py @@ -78,8 +78,32 @@ def headers(self): @GET('headers') @header('first') - @header('second_header', 'SecondHeader') - def headers_in_args(self, first, second_header): + @header('second_header', 'Second-Header') + @header('Third-Header', 'Third header value') + @header('fourth_header', 'Fourth-Header') + @content('application/json') + @accept('application/xml') + def headers_in_args(self, first, second_header, fourth_header='WXYZ'): + """Return header dict.""" + + @GET('headers') + @header('A', '1') + @header('A', '2') + @header('A', '3') + @header('B', ['X', 'Y', 'Z']) + def headers_multivalue_headers(self): + """Return header dict.""" + + @GET('headers') + @accept('text/plain') + @accept('application/json') + @accept('application/xml') + def headers_multi_accept(self): + """Return header dict.""" + + @GET('headers') + @header('A', 'a') + def headers_multivalue_headers_with_override(self, a=['1', '2', '3']): """Return header dict.""" @GET('get') diff --git a/tests/httpbin_test.py b/tests/httpbin_test.py index 2569972..19aea4c 100644 --- a/tests/httpbin_test.py +++ b/tests/httpbin_test.py @@ -113,7 +113,6 @@ def test_user_agent(client): def test_headers(client): """ """ - def ci(d): return CaseInsensitiveDict(d) @@ -135,11 +134,39 @@ def ci(d): assert ci(res['headers'])['B'] == 'BB' + +@pytest.mark.parametrize("client", pytest_params) +def test_headers_in_args(client): + """ + """ # Check passing header value in arguments res = client.headers_in_args('1234', 'ABCD') - assert ci(res['headers'])['First'] == '1234' - assert ci(res['headers'])['SecondHeader'] == 'ABCD' + ci = CaseInsensitiveDict(res['headers']) + + assert ci['First'] == '1234' + assert ci['Second-Header'] == 'ABCD' + assert ci['Third-Header'] == 'Third header value' + assert ci['Fourth-Header'] == 'WXYZ' + assert ci['Content-Type'] == 'application/json' + assert ci['Accept'] == 'application/xml' + + +@pytest.mark.parametrize("client", pytest_params) +def test_headers_multivalue(client): + """ + """ + # Check passing header value in arguments + res = client.headers_multivalue_headers() + ci = CaseInsensitiveDict(res['headers']) + + assert ci['A'] == '3, 2, 1' + assert ci['B'] == 'X, Y, Z' + + res = client.headers_multi_accept() + ci = CaseInsensitiveDict(res['headers']) + + assert ci['accept'] == 'application/xml, application/json, text/plain' @pytest.mark.parametrize("client", pytest_params) From e56928d7785f819f5b6c5577a4785f3d944c8220 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 26 Dec 2021 17:01:45 +0100 Subject: [PATCH 16/48] Updated header decorator docs --- README.rst | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 9628bbe..d99da04 100644 --- a/README.rst +++ b/README.rst @@ -249,7 +249,26 @@ name matches one of the arguments, e.g.: @GET('breed/{breed_name}/list') @header('accept') - def list_subbreeds(self, breed_name, accept): + @header('user_agent', 'user-agent') + def list_subbreeds(self, breed_name, accept, user_agent='decorest'): + """List all sub-breeds""" + +In case the first argument of the header decorator matches one of the +method args, it's optional second value determines the actual header +name that will be send in the request. A default value for the header +in such case must be provided in the method signature. + +Multiple values for the same header can be provided either as separate +decorators or as a decorator with a list of values, e.g.: + +.. code-block:: python + + @GET('breed/{breed_name}/list') + @header('abc', 'a') + @header('abc', 'b') + @header('abc', 'c') + @header('xyz', ['x', 'y', 'z']) + def list_subbreeds(self, breed_name): """List all sub-breeds""" @body @@ -346,6 +365,25 @@ This decorator is a shortcut for :py:`@header('accept', ...)`, e.g: def list_subbreeds(self, breed_name): """List all sub-breeds""" +Multiple :py:`@accept()` decorators can be added and will be joined into +a list, e.g.: + +.. code-block:: python + + @GET('breed/{breed_name}/list') + @content('application/json') + @accept('application/xml') + @accept('application/json') + @accept('text/plain') + def list_subbreeds(self, breed_name): + """List all sub-breeds""" + +will submit the following header to the server: + +.. code-block:: bash + + Accept: text/plain, application/json, application/xml + @endpoint ~~~~~~~~~ This decorator enables to define a default endpoint for the service, From 99e384be4917c34d2a1ecfecc4288da02bf9bce9 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 26 Dec 2021 17:13:01 +0100 Subject: [PATCH 17/48] Updated changelog --- CHANGELOG.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3433dc1..0fa5a25 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,9 @@ * Deprecated Python 2 support * Added typing support and mypy tests +* Added asyncio support through httpx +* Fixed handling of multiple header decorators + 0.0.7 (2021-11-27) ++++++++++++++++++ From dbbd12c3d7475f62fec816fcd10b9ddb6dd02a1a Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 26 Dec 2021 17:49:36 +0100 Subject: [PATCH 18/48] Applied yapf formatting --- decorest/DELETE.py | 5 +- decorest/GET.py | 1 + decorest/HEAD.py | 5 +- decorest/OPTIONS.py | 5 +- decorest/PATCH.py | 5 +- decorest/POST.py | 5 +- decorest/PUT.py | 1 + decorest/__init__.py | 6 +- decorest/client.py | 4 +- decorest/decorator_utils.py | 12 +- decorest/decorators.py | 27 ++-- decorest/request.py | 27 ++-- decorest/types.py | 5 +- examples/httpbin/httpbin_async_client.py | 6 +- examples/httpbin/httpbin_client.py | 1 - .../httpbin/httpbin_client_with_typing.py | 17 +-- examples/swagger_petstore/petstore_client.py | 3 - .../petstore_client_with_typing.py | 3 - tests/decorators_tests.py | 15 +-- tests/httpbin_async_test.py | 121 ++++++++++-------- tests/httpbin_test.py | 3 +- tests/petstore_test.py | 6 +- tests/petstore_test_with_typing.py | 7 +- 23 files changed, 141 insertions(+), 149 deletions(-) diff --git a/decorest/DELETE.py b/decorest/DELETE.py index cd5ed58..b48e317 100644 --- a/decorest/DELETE.py +++ b/decorest/DELETE.py @@ -34,12 +34,13 @@ def __call__(self, func: TDecor) -> TDecor: set_decor(func, 'http_method', HttpMethod.DELETE) if asyncio.iscoroutinefunction(func): + @wraps(func) async def async_delete_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: - return await super(DELETE, self).call_async(func, - *args, **kwargs) + return await super(DELETE, + self).call_async(func, *args, **kwargs) return typing.cast(TDecor, async_delete_decorator) diff --git a/decorest/GET.py b/decorest/GET.py index 1b63c74..fce7398 100644 --- a/decorest/GET.py +++ b/decorest/GET.py @@ -34,6 +34,7 @@ def __call__(self, func: TDecor) -> TDecor: set_decor(func, 'http_method', HttpMethod.GET) if asyncio.iscoroutinefunction(func): + @wraps(func) async def async_get_decorator(*args: typing.Any, **kwargs: typing.Any) \ diff --git a/decorest/HEAD.py b/decorest/HEAD.py index e19ba8d..b2b8ecf 100644 --- a/decorest/HEAD.py +++ b/decorest/HEAD.py @@ -34,12 +34,13 @@ def __call__(self, func: TDecor) -> TDecor: set_decor(func, 'http_method', HttpMethod.HEAD) if asyncio.iscoroutinefunction(func): + @wraps(func) async def async_head_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: - return await super(HEAD, self).call_async( - func, *args, **kwargs) + return await super(HEAD, + self).call_async(func, *args, **kwargs) return typing.cast(TDecor, async_head_decorator) diff --git a/decorest/OPTIONS.py b/decorest/OPTIONS.py index f633d03..4c93996 100644 --- a/decorest/OPTIONS.py +++ b/decorest/OPTIONS.py @@ -34,12 +34,13 @@ def __call__(self, func: TDecor) -> TDecor: set_decor(func, 'http_method', HttpMethod.OPTIONS) if asyncio.iscoroutinefunction(func): + @wraps(func) async def async_options_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: - return await super(OPTIONS, self).call_async( - func, *args, **kwargs) + return await super(OPTIONS, + self).call_async(func, *args, **kwargs) return typing.cast(TDecor, async_options_decorator) diff --git a/decorest/PATCH.py b/decorest/PATCH.py index be395c3..6e940c6 100644 --- a/decorest/PATCH.py +++ b/decorest/PATCH.py @@ -34,12 +34,13 @@ def __call__(self, func: TDecor) -> TDecor: set_decor(func, 'http_method', HttpMethod.PATCH) if asyncio.iscoroutinefunction(func): + @wraps(func) async def async_patch_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: - return await super(PATCH, self).call_async( - func, *args, **kwargs) + return await super(PATCH, + self).call_async(func, *args, **kwargs) return typing.cast(TDecor, async_patch_decorator) diff --git a/decorest/POST.py b/decorest/POST.py index 2a15354..dfad0bf 100644 --- a/decorest/POST.py +++ b/decorest/POST.py @@ -34,12 +34,13 @@ def __call__(self, func: TDecor) -> TDecor: set_decor(func, 'http_method', HttpMethod.POST) if asyncio.iscoroutinefunction(func): + @wraps(func) async def async_post_decorator(*args: typing.Any, **kwargs: typing.Any) \ -> typing.Any: - return await super(POST, self).call_async(func, *args, - **kwargs) + return await super(POST, + self).call_async(func, *args, **kwargs) return typing.cast(TDecor, async_post_decorator) diff --git a/decorest/PUT.py b/decorest/PUT.py index 28e2d3c..3c96377 100644 --- a/decorest/PUT.py +++ b/decorest/PUT.py @@ -34,6 +34,7 @@ def __call__(self, func: TDecor) -> TDecor: set_decor(func, 'http_method', HttpMethod.PUT) if asyncio.iscoroutinefunction(func): + @wraps(func) async def async_put_decorator(*args: typing.Any, **kwargs: typing.Any) \ diff --git a/decorest/__init__.py b/decorest/__init__.py index 0df6ea3..7552395 100644 --- a/decorest/__init__.py +++ b/decorest/__init__.py @@ -31,9 +31,9 @@ __all__ = [ 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'RestClient', - 'HTTPErrorWrapper', 'HttpMethod', 'HttpStatus', 'HttpRequest', - 'query', 'body', 'header', 'on', 'accept', 'content', 'endpoint', - 'timeout', 'stream', 'form', 'multipart' + 'HTTPErrorWrapper', 'HttpMethod', 'HttpStatus', 'HttpRequest', 'query', + 'body', 'header', 'on', 'accept', 'content', 'endpoint', 'timeout', + 'stream', 'form', 'multipart' ] __version__ = "0.1.0" diff --git a/decorest/client.py b/decorest/client.py index 055ccf5..694af1b 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -115,7 +115,7 @@ def build_request(self, path_components: typing.List[str]) -> str: """ LOG.debug("Building request from path tokens: %s", path_components) - req = urllib.parse.urljoin( - normalize_url(self.endpoint), "/".join(path_components)) + req = urllib.parse.urljoin(normalize_url(self.endpoint), + "/".join(path_components)) return req diff --git a/decorest/decorator_utils.py b/decorest/decorator_utils.py index 26b9f4c..332115f 100644 --- a/decorest/decorator_utils.py +++ b/decorest/decorator_utils.py @@ -57,8 +57,7 @@ def set_decor(t: typing.Any, name: str, value: typing.Any) -> None: d[name] = value -def set_header_decor(t: typing.Any, - value: HeaderDict) -> None: +def set_header_decor(t: typing.Any, value: HeaderDict) -> None: """Decorate a function or class with header decorator.""" if hasattr(t, '__wrapped__') and hasattr(t.__wrapped__, DECOR_KEY): setattr(t, DECOR_KEY, t.__wrapped__.__decorest__) @@ -132,20 +131,17 @@ def get_on_decor(t: typing.Any) \ def get_accept_decor(t: typing.Any) -> typing.Optional[str]: """Return accept decor value.""" - return typing.cast(typing.Optional[str], - get_decor(t, 'accept')) + return typing.cast(typing.Optional[str], get_decor(t, 'accept')) def get_content_decor(t: typing.Any) -> typing.Optional[str]: """Return content-type decor value.""" - return typing.cast(typing.Optional[str], - get_decor(t, 'content')) + return typing.cast(typing.Optional[str], get_decor(t, 'content')) def get_timeout_decor(t: typing.Any) -> typing.Optional[numbers.Real]: """Return timeout decor value.""" - return typing.cast(typing.Optional[numbers.Real], - get_decor(t, 'timeout')) + return typing.cast(typing.Optional[numbers.Real], get_decor(t, 'timeout')) def get_stream_decor(t: typing.Any) -> bool: diff --git a/decorest/decorators.py b/decorest/decorators.py index 98445e2..076fc33 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -43,7 +43,6 @@ def on(status: typing.Union[types.ellipsis, int], The handler is a function or lambda which will receive as the sole parameter the requests response object. """ - def on_decorator(t: TDecor) -> TDecor: if status is Ellipsis: # type: ignore set_decor(t, 'on', {HttpStatus.ANY: handler}) @@ -59,7 +58,6 @@ def on_decorator(t: TDecor) -> TDecor: def query(name: str, value: typing.Optional[str] = None) \ -> typing.Callable[[TDecor], TDecor]: """Query parameter decorator.""" - def query_decorator(t: TDecor) -> TDecor: value_ = value if inspect.isclass(t): @@ -75,7 +73,6 @@ def query_decorator(t: TDecor) -> TDecor: def form(name: str, value: typing.Optional[str] = None) \ -> typing.Callable[[TDecor], TDecor]: """Form parameter decorator.""" - def form_decorator(t: TDecor) -> TDecor: value_ = value if inspect.isclass(t): @@ -91,7 +88,6 @@ def form_decorator(t: TDecor) -> TDecor: def multipart(name: str, value: typing.Optional[str] = None) \ -> typing.Callable[[TDecor], TDecor]: """Multipart parameter decorator.""" - def multipart_decorator(t: TDecor) -> TDecor: value_ = value if inspect.isclass(t): @@ -107,7 +103,6 @@ def multipart_decorator(t: TDecor) -> TDecor: def header(name: str, value: typing.Optional[str] = None) \ -> typing.Callable[[TDecor], TDecor]: """Header class and method decorator.""" - def header_decorator(t: TDecor) -> TDecor: set_header_decor(t, CaseInsensitiveDict({name: value or name})) return t @@ -117,7 +112,6 @@ def header_decorator(t: TDecor) -> TDecor: def endpoint(value: str) -> typing.Callable[[TDecor], TDecor]: """Endpoint class and method decorator.""" - def endpoint_decorator(t: TDecor) -> TDecor: set_decor(t, 'endpoint', value) return t @@ -127,7 +121,6 @@ def endpoint_decorator(t: TDecor) -> TDecor: def content(value: str) -> typing.Callable[[TDecor], TDecor]: """Content-type header class and method decorator.""" - def content_decorator(t: TDecor) -> TDecor: set_header_decor(t, CaseInsensitiveDict({'Content-Type': value})) return t @@ -137,7 +130,6 @@ def content_decorator(t: TDecor) -> TDecor: def accept(value: str) -> typing.Callable[[TDecor], TDecor]: """Accept header class and method decorator.""" - def accept_decorator(t: TDecor) -> TDecor: set_header_decor(t, CaseInsensitiveDict({'Accept': value})) return t @@ -153,7 +145,6 @@ def body(name: str, Determines which method argument provides the body. """ - def body_decorator(t: TDecor) -> TDecor: set_decor(t, 'body', (name, serializer)) return t @@ -167,7 +158,6 @@ def timeout(value: float) -> typing.Callable[[TDecor], TDecor]: Specifies a default timeout value for method or entire API. """ - def timeout_decorator(t: TDecor) -> TDecor: set_decor(t, 'timeout', value) return t @@ -188,7 +178,6 @@ def stream(t: TDecor) -> TDecor: class HttpMethodDecorator: """Abstract decorator for HTTP method decorators.""" - def __init__(self, path: str): """Initialize decorator with endpoint relative path.""" self.path_template = path @@ -213,7 +202,8 @@ async def call_async(self, func: typing.Callable[..., typing.Any], and http_request.is_multipart_request: # TODO: Why do I have to do this? if 'headers' in http_request.kwargs: - http_request.kwargs['headers'].pop('content-type', None) + http_request.kwargs['headers'].pop( + 'content-type', None) result = await self.__dispatch_async(http_request) except Exception as e: @@ -221,8 +211,8 @@ async def call_async(self, func: typing.Callable[..., typing.Any], return http_request.handle(result) - def call(self, func: typing.Callable[..., typing.Any], - *args: typing.Any, **kwargs: typing.Any) -> typing.Any: + def call(self, func: typing.Callable[..., typing.Any], *args: typing.Any, + **kwargs: typing.Any) -> typing.Any: """Execute the API HTTP request.""" http_request = HttpRequest(func, self.path_template, args, kwargs) @@ -239,7 +229,8 @@ def call(self, func: typing.Callable[..., typing.Any], and http_request.is_multipart_request: # TODO: Why do I have to do this? if 'headers' in http_request.kwargs: - http_request.kwargs['headers'].pop('content-type', None) + http_request.kwargs['headers'].pop( + 'content-type', None) result = self.__dispatch(http_request) except Exception as e: @@ -263,8 +254,7 @@ def __dispatch(self, http_request: HttpRequest) -> typing.Any: method = http_request.http_method.value[0].lower() ctx = http_request.execution_context - return methodcaller(method, - http_request.req, + return methodcaller(method, http_request.req, **http_request.kwargs)(ctx) async def __dispatch_async(self, http_request: HttpRequest) -> typing.Any: @@ -286,8 +276,7 @@ async def __dispatch_async(self, http_request: HttpRequest) -> typing.Any: if not isinstance(http_request.execution_context, httpx.AsyncClient): async with httpx.AsyncClient() as client: - return await client.request(method.upper(), - http_request.req, + return await client.request(method.upper(), http_request.req, **http_request.kwargs) else: return await http_request.execution_context.request( diff --git a/decorest/request.py b/decorest/request.py index cb00497..8df3e2c 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -48,10 +48,9 @@ class HttpRequest: execution_context: typing.Any rest_client: RestClient - def __init__(self, func: typing.Callable[..., typing.Any], - path_template: str, - args: typing.Tuple[typing.Any, ...], - kwargs: ArgsDict): + def __init__(self, func: typing.Callable[..., + typing.Any], path_template: str, + args: typing.Tuple[typing.Any, ...], kwargs: ArgsDict): """ Construct HttpRequest instance. @@ -69,9 +68,8 @@ def __init__(self, func: typing.Callable[..., typing.Any], HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.HEAD, HttpMethod.OPTIONS): - raise ValueError( - 'Unsupported HTTP method: {method}'.format( - method=self.http_method)) + raise ValueError('Unsupported HTTP method: {method}'.format( + method=self.http_method)) self.rest_client = args[0] args_dict = dict_from_args(func, *args) @@ -87,9 +85,9 @@ def __init__(self, func: typing.Callable[..., typing.Any], query_parameters = self.__merge_args(args_dict, func, 'query') form_parameters = self.__merge_args(args_dict, func, 'form') multipart_parameters = self.__merge_args(args_dict, func, 'multipart') - header_parameters = CaseInsensitiveDict(merge_dicts( - get_header_decor(self.rest_client.__class__), - self.__merge_args(args_dict, func, 'header'))) + header_parameters = CaseInsensitiveDict( + merge_dicts(get_header_decor(self.rest_client.__class__), + self.__merge_args(args_dict, func, 'header'))) # Merge header parameters with default values, treat header # decorators with 2 params as default values only if they @@ -120,8 +118,8 @@ def __init__(self, func: typing.Callable[..., typing.Any], auth = self.rest_client._auth() # Get status handlers - self.on_handlers = merge_dicts(get_on_decor(self.rest_client.__class__), - get_on_decor(func)) + self.on_handlers = merge_dicts( + get_on_decor(self.rest_client.__class__), get_on_decor(func)) # Get timeout request_timeout = get_timeout_decor(self.rest_client.__class__) @@ -268,9 +266,8 @@ def __validate_decor(self, decor: str, kwargs: ArgsDict, cls(class): Expected type of decorator parameter """ if not isinstance(kwargs[decor], cls): - raise TypeError( - "{} value must be an instance of {}".format( - decor, cls.__name__)) + raise TypeError("{} value must be an instance of {}".format( + decor, cls.__name__)) def __merge_args(self, args_dict: ArgsDict, func: typing.Callable[..., typing.Any], decor: str) \ diff --git a/decorest/types.py b/decorest/types.py index e2e1841..0734661 100644 --- a/decorest/types.py +++ b/decorest/types.py @@ -62,9 +62,9 @@ class HttpStatus(DIntEnum): TDecor = typing.TypeVar('TDecor', bound=typing.Callable[..., typing.Any]) - if typing.TYPE_CHECKING: - class ellipsis(enum.Enum): # noqa N801 + + class ellipsis(enum.Enum): # noqa N801 """ Ellipsis type for typechecking. @@ -72,6 +72,7 @@ class ellipsis(enum.Enum): # noqa N801 for the 'on' decorator. """ Ellipsis = "..." + Ellipsis = ellipsis.Ellipsis else: ellipsis = type(Ellipsis) diff --git a/examples/httpbin/httpbin_async_client.py b/examples/httpbin/httpbin_async_client.py index f0d377f..9acdfca 100644 --- a/examples/httpbin/httpbin_async_client.py +++ b/examples/httpbin/httpbin_async_client.py @@ -53,7 +53,6 @@ def parse_image(response): @endpoint('http://httpbin.org') class HttpBinAsyncClient(RestClient): """Client to HttpBin service (httpbin.org).""" - @GET('ip') async def ip(self): """Return Origin IP.""" @@ -161,7 +160,10 @@ async def status_code(self, code): @query('first_name', 'firstName') @query('last_name', 'lastName') @query('nickname') - async def response_headers(self, first_name, last_name, nickname='httpbin'): + async def response_headers(self, + first_name, + last_name, + nickname='httpbin'): """Return given response headers.""" @GET('redirect/{n}') diff --git a/examples/httpbin/httpbin_client.py b/examples/httpbin/httpbin_client.py index 3ca2332..44c57c9 100644 --- a/examples/httpbin/httpbin_client.py +++ b/examples/httpbin/httpbin_client.py @@ -53,7 +53,6 @@ def parse_image(response): @endpoint('http://httpbin.org') class HttpBinClient(RestClient): """Client to HttpBin service (httpbin.org).""" - @GET('ip') def ip(self): """Return Origin IP.""" diff --git a/examples/httpbin/httpbin_client_with_typing.py b/examples/httpbin/httpbin_client_with_typing.py index ba4797b..a187ca2 100644 --- a/examples/httpbin/httpbin_client_with_typing.py +++ b/examples/httpbin/httpbin_client_with_typing.py @@ -58,7 +58,6 @@ def parse_image(response: typing.Any) -> typing.Optional[Image]: @endpoint('http://httpbin.org') class HttpBinClientWithTyping(RestClient): """Client to HttpBin service (httpbin.org).""" - @GET('ip') def ip(self) -> JsonDictType: """Return Origin IP.""" @@ -108,8 +107,8 @@ def post(self, post_data: str) -> JsonDictType: @multipart('part1') @multipart('part_2', 'part2') @multipart('test') - def post_multipart(self, part1: typing.Any, - part_2: typing.Any, test: typing.Any) -> JsonDictType: + def post_multipart(self, part1: typing.Any, part_2: typing.Any, + test: typing.Any) -> JsonDictType: """Return multipart POST data.""" @PATCH('patch') @@ -171,7 +170,9 @@ def status_code(self, code: int) -> JsonDictType: @query('first_name', 'firstName') @query('last_name', 'lastName') @query('nickname') - def response_headers(self, first_name: str, last_name: str, + def response_headers(self, + first_name: str, + last_name: str, nickname: str = 'httpbin') -> JsonDictType: """Return given response headers.""" @@ -219,8 +220,8 @@ def hidden_basic_auth(self, user: str, passwd: str) -> JsonDictType: """404'd BasicAuth.""" @GET('digest-auth/{qop}/{user}/{passwd}/{algorithm}/never') - def digest_auth_algorithm(self, qop: str, user: str, - passwd: str, algorithm: str) -> JsonDictType: + def digest_auth_algorithm(self, qop: str, user: str, passwd: str, + algorithm: str) -> JsonDictType: """Challenge HTTP Digest Auth.""" @GET('digest-auth/{qop}/{user}/{passwd}') @@ -243,8 +244,8 @@ def delay(self, n: int) -> JsonDictType: @query('duration') @query('delay') @query('code') - def drip(self, numbytes: int, duration: float, - delay: int, code: int) -> JsonDictType: + def drip(self, numbytes: int, duration: float, delay: int, + code: int) -> JsonDictType: """Drip data over a duration. Drip data over a duration after an optional initial delay, then diff --git a/examples/swagger_petstore/petstore_client.py b/examples/swagger_petstore/petstore_client.py index f055071..1e1f5df 100644 --- a/examples/swagger_petstore/petstore_client.py +++ b/examples/swagger_petstore/petstore_client.py @@ -35,7 +35,6 @@ @endpoint('http://petstore.example.com') class PetAPI(RestClient): """Everything about your Pets.""" - @POST('pet') @content('application/json') @accept('application/json') @@ -79,7 +78,6 @@ def upload_pet_image(self, pet_id, image): class StoreAPI(RestClient): """Access to Petstore orders.""" - @GET('store/inventory') def get_inventory(self): """Return pet inventories by status.""" @@ -100,7 +98,6 @@ def delete_order(self, order_id): class UserAPI(RestClient): """Operations about user.""" - @POST('user') @body('user', lambda o: json.dumps(o)) @on(200, lambda r: True) diff --git a/examples/swagger_petstore/petstore_client_with_typing.py b/examples/swagger_petstore/petstore_client_with_typing.py index 050ea9f..5830b70 100644 --- a/examples/swagger_petstore/petstore_client_with_typing.py +++ b/examples/swagger_petstore/petstore_client_with_typing.py @@ -40,7 +40,6 @@ @endpoint('http://petstore.example.com') class PetAPI(RestClient): """Everything about your Pets.""" - @POST('pet') @content('application/json') @accept('application/json') @@ -86,7 +85,6 @@ def upload_pet_image(self, pet_id: str, image: Image) -> None: class StoreAPI(RestClient): """Access to Petstore orders.""" - @GET('store/inventory') def get_inventory(self) -> JsonDictType: """Return pet inventories by status.""" @@ -107,7 +105,6 @@ def delete_order(self, order_id: str) -> None: class UserAPI(RestClient): """Operations about user.""" - @POST('user') @body('user', lambda o: json.dumps(o)) @on(200, lambda r: True) diff --git a/tests/decorators_tests.py b/tests/decorators_tests.py index de397fd..b5f4f63 100644 --- a/tests/decorators_tests.py +++ b/tests/decorators_tests.py @@ -32,7 +32,6 @@ @endpoint('https://dog.ceo/') class DogClient(RestClient): """DogClient client""" - @GET('breed/{breed_name}/list') def list_subbreeds(self, breed_name: str) -> typing.Any: """List all sub-breeds""" @@ -98,12 +97,12 @@ def options(self, a: str) -> typing.Any: @stream @query('size') @query('offset', 'off') - def stream_range(self, n: int, m: int, - size: int, offset: int) -> typing.Any: + def stream_range(self, n: int, m: int, size: int, + offset: int) -> typing.Any: """Get data range""" - def plain_stream_range(self, n: int, m: int, - size: int, offset: int) -> typing.Any: + def plain_stream_range(self, n: int, m: int, size: int, + offset: int) -> typing.Any: """Get data range""" @@ -137,11 +136,7 @@ class and methods. 'key1': 'key1', 'key2': 'keyTwo' } - assert get_query_decor(DogClient.queries) == { - 'a': 'a', - 'b': 'b', - 'c': 'd' - } + assert get_query_decor(DogClient.queries) == {'a': 'a', 'b': 'b', 'c': 'd'} assert get_stream_decor(DogClient.stream_range) is True assert get_query_decor(DogClient.stream_range) == { diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index eb78133..e7bfbee 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -46,8 +46,9 @@ def client() -> HttpBinAsyncClient: host = os.environ["HTTPBIN_HOST"] port = os.environ["HTTPBIN_80_TCP_PORT"] - return HttpBinAsyncClient("http://{host}:{port}".format(host=host, port=port), - backend='httpx') + return HttpBinAsyncClient("http://{host}:{port}".format(host=host, + port=port), + backend='httpx') @pytest.fixture @@ -56,8 +57,9 @@ def basic_auth_client(): host = os.environ["HTTPBIN_HOST"] port = os.environ["HTTPBIN_80_TCP_PORT"] - client = HttpBinAsyncClient("http://{host}:{port}".format(host=host, port=port), - backend='httpx') + client = HttpBinAsyncClient("http://{host}:{port}".format(host=host, + port=port), + backend='httpx') client._set_auth(HTTPBasicAuth('user', 'password')) return client @@ -104,14 +106,14 @@ async def test_user_agent(client): async def test_headers(client): """ """ - def ci(d): return CaseInsensitiveDict(d) # Check res = await client.headers(header={'A': 'AA', 'B': 'CC'}) - assert ci(res['headers'])['User-Agent'] == 'decorest/{v}'.format(v=__version__) + assert ci( + res['headers'])['User-Agent'] == 'decorest/{v}'.format(v=__version__) assert ci(res['headers'])['A'] == 'AA' assert ci(res['headers'])['B'] == 'CC' @@ -182,10 +184,9 @@ async def test_post_multipart_decorators(client): """ """ f = 'tests/testdata/multipart.dat' - res = await client.post_multipart(bytes('TEST1', 'utf-8'), - bytes('TEST2', 'utf-8'), - ('filename', open(f, 'rb'), - 'text/plain')) + res = await client.post_multipart( + bytes('TEST1', 'utf-8'), bytes('TEST2', 'utf-8'), + ('filename', open(f, 'rb'), 'text/plain')) assert res["files"]["part1"] == 'TEST1' assert res["files"]["part2"] == 'TEST2' @@ -219,9 +220,11 @@ async def test_multi_put(client): """ request_count = 100 async with client._async_session() as s: - reqs = [asyncio.ensure_future(s.put({i: str(i)}, - content="application/json")) - for i in range(0, request_count)] + reqs = [ + asyncio.ensure_future( + s.put({i: str(i)}, content="application/json")) + for i in range(0, request_count) + ] reqs_result = await asyncio.gather(*reqs) @@ -257,9 +260,9 @@ async def test_anything_anything(client): """ data = {"a": "b", "c": "1"} res = await client.anything_anything("something", - data, - content="application/json", - query=data) + data, + content="application/json", + query=data) assert res["args"] == data assert res["json"] == data @@ -322,8 +325,8 @@ async def test_redirect(client): """ """ res = await client.redirect(2, - on={302: lambda r: 'REDIRECTED'}, - follow_redirects=False) + on={302: lambda r: 'REDIRECTED'}, + follow_redirects=False) assert res == 'REDIRECTED' @@ -333,8 +336,8 @@ async def test_redirect_to(client): """ """ res = await client.redirect_to('http://httpbin.org', - on={302: lambda r: 'REDIRECTED'}, - follow_redirects=False) + on={302: lambda r: 'REDIRECTED'}, + follow_redirects=False) assert res == 'REDIRECTED' @@ -344,9 +347,9 @@ async def test_redirect_to_foo(client): """ """ res = await client.redirect_to_foo('http://httpbin.org', - 307, - on={307: lambda r: 'REDIRECTED'}, - follow_redirects=False) + 307, + on={307: lambda r: 'REDIRECTED'}, + follow_redirects=False) assert res == 'REDIRECTED' @@ -355,9 +358,8 @@ async def test_redirect_to_foo(client): async def test_relative_redirect(client): """ """ - res = await client.relative_redirect(1, - on={302: lambda r: r.headers['Location']}, - follow_redirects=False) + res = await client.relative_redirect( + 1, on={302: lambda r: r.headers['Location']}, follow_redirects=False) assert res == '/get' @@ -366,9 +368,8 @@ async def test_relative_redirect(client): async def test_absolute_redirect(client): """ """ - res = await client.absolute_redirect(1, - on={302: lambda r: r.headers['Location']}, - follow_redirects=False) + res = await client.absolute_redirect( + 1, on={302: lambda r: r.headers['Location']}, follow_redirects=False) assert res.endswith('/get') @@ -390,9 +391,11 @@ async def test_cookies(client): async def test_cookies_set(client): """ """ - res = await client.cookies_set( - query={"cookie1": "A", "cookie2": "B"}, - follow_redirects=True) + res = await client.cookies_set(query={ + "cookie1": "A", + "cookie2": "B" + }, + follow_redirects=True) assert res["cookies"]["cookie1"] == "A" assert res["cookies"]["cookie2"] == "B" @@ -404,9 +407,11 @@ async def test_cookies_session(client): """ s = client._async_session() pprint.pprint(type(s)) - res = await s.cookies_set( - query={"cookie1": "A", "cookie2": "B"}, - follow_redirects=True) + res = await s.cookies_set(query={ + "cookie1": "A", + "cookie2": "B" + }, + follow_redirects=True) assert res["cookies"]["cookie1"] == "A" assert res["cookies"]["cookie2"] == "B" @@ -425,9 +430,11 @@ async def test_cookies_session_with_contextmanager(client): """ async with client._async_session() as s: s._requests_session.verify = False - res = await s.cookies_set( - query={"cookie1": "A", "cookie2": "B"}, - follow_redirects=True) + res = await s.cookies_set(query={ + "cookie1": "A", + "cookie2": "B" + }, + follow_redirects=True) assert res["cookies"]["cookie1"] == "A" assert res["cookies"]["cookie2"] == "B" @@ -442,10 +449,12 @@ async def test_cookies_session_with_contextmanager(client): async def test_cookies_delete(client): """ """ - await client.cookies_set(query={"cookie1": "A", "cookie2": "B"}, + await client.cookies_set(query={ + "cookie1": "A", + "cookie2": "B" + }, follow_redirects=True) - await client.cookies_delete(query={"cookie1": None}, - follow_redirects=True) + await client.cookies_delete(query={"cookie1": None}, follow_redirects=True) res = await client.cookies() assert "cookie1" not in res["cookies"] @@ -480,8 +489,9 @@ async def test_hidden_basic_auth(client): """ """ res = await client.hidden_basic_auth('user', - 'password', - auth=HTTPBasicAuth('user', 'password')) + 'password', + auth=HTTPBasicAuth( + 'user', 'password')) assert res['authenticated'] is True @@ -493,10 +503,10 @@ async def test_digest_auth_algorithm(client): auth = httpx.DigestAuth('user', 'password') res = await client.digest_auth_algorithm('auth', - 'user', - 'password', - 'MD5', - auth=auth) + 'user', + 'password', + 'MD5', + auth=auth) assert res['authenticated'] is True @@ -507,8 +517,7 @@ async def test_digest_auth(client): """ auth = httpx.DigestAuth('user', 'password') - res = await client.digest_auth( - 'auth', 'user', 'password', auth=auth) + res = await client.digest_auth('auth', 'user', 'password', auth=auth) assert res['authenticated'] is True @@ -621,15 +630,17 @@ async def test_cache_n(client): async def test_etag(client): """ """ - status_code = await client.etag('etag', - header={'If-Match': 'notetag'}, - on={HttpStatus.ANY: lambda r: r.status_code}) + status_code = await client.etag( + 'etag', + header={'If-Match': 'notetag'}, + on={HttpStatus.ANY: lambda r: r.status_code}) assert status_code == 412 - status_code = await client.etag('etag', - header={'If-Match': 'etag'}, - on={HttpStatus.ANY: lambda r: r.status_code}) + status_code = await client.etag( + 'etag', + header={'If-Match': 'etag'}, + on={HttpStatus.ANY: lambda r: r.status_code}) assert status_code == 200 diff --git a/tests/httpbin_test.py b/tests/httpbin_test.py index 19aea4c..50ef8c0 100644 --- a/tests/httpbin_test.py +++ b/tests/httpbin_test.py @@ -119,7 +119,8 @@ def ci(d): # Check res = client.headers(header={'A': 'AA', 'B': 'CC'}) - assert ci(res['headers'])['User-Agent'] == 'decorest/{v}'.format(v=__version__) + assert ci( + res['headers'])['User-Agent'] == 'decorest/{v}'.format(v=__version__) assert ci(res['headers'])['A'] == 'AA' assert ci(res['headers'])['B'] == 'CC' diff --git a/tests/petstore_test.py b/tests/petstore_test.py index 2b26b82..fb81472 100644 --- a/tests/petstore_test.py +++ b/tests/petstore_test.py @@ -32,9 +32,9 @@ def client(backend: str) -> PetstoreClient: host = "localhost" port = os.environ['PETSTORE_8080_TCP_PORT'] - return PetstoreClient( - 'http://{host}:{port}/api'.format(host=host, port=port), - backend=backend) + return PetstoreClient('http://{host}:{port}/api'.format(host=host, + port=port), + backend=backend) # Prepare pytest params diff --git a/tests/petstore_test_with_typing.py b/tests/petstore_test_with_typing.py index 333c981..311c05a 100644 --- a/tests/petstore_test_with_typing.py +++ b/tests/petstore_test_with_typing.py @@ -34,9 +34,9 @@ def client(backend: typing.Literal['requests', 'httpx']) \ host = "localhost" port = os.environ['PETSTORE_8080_TCP_PORT'] - return PetstoreClientWithTyping( - 'http://{host}:{port}/api'.format(host=host, port=port), - backend=backend) + return PetstoreClientWithTyping('http://{host}:{port}/api'.format( + host=host, port=port), + backend=backend) client_requests = client('requests') @@ -151,4 +151,3 @@ def test_user_methods(client: PetstoreClientWithTyping) -> None: assert res['password'] == 'guess' client.delete_user('swagger') - From f96e1a11d0a68998972b6f27e36d562a703f7a27 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 26 Dec 2021 18:10:28 +0100 Subject: [PATCH 19/48] Added checks for formatting and README.rst validity --- .github/workflows/workflow.yml | 2 +- tox.ini | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 8b1b087..d31d971 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -28,4 +28,4 @@ jobs: python-version: ${{ matrix.python-version }} - run: pip install tox tox-gh-actions tox-docker==$TOX_DOCKER_VERSION - - run: tox -c tox.ini -e flake8,mypy,basic,swaggerpetstore,httpbin,asynchttpbin \ No newline at end of file + - run: tox -c tox.ini -e yapf,rstcheck,flake8,mypy,basic,swaggerpetstore,httpbin,asynchttpbin \ No newline at end of file diff --git a/tox.ini b/tox.ini index 92bfdcd..d5d1599 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,23 @@ deps = requests httpx +[testenv:yapf] +basepython = python3 +skip_install = true +deps = + yapf +commands = + yapf -dr decorest tests examples + +[testenv:rstcheck] +basepython = python3 +skip_install = true +deps = + rstcheck + pygments +commands = + rstcheck README.rst + [testenv:flake8] basepython = python3 skip_install = true From 225cec907a80c872f8336677603e66c148b8aeea Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 26 Dec 2021 19:02:49 +0100 Subject: [PATCH 20/48] Treat warnings in tests as errors --- tests/httpbin_async_test.py | 23 ++++++++++++++--------- tests/httpbin_test.py | 31 ++++++++++++++++++------------- tox.ini | 10 ++++++---- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index e7bfbee..0a24174 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -171,26 +171,31 @@ async def test_post_form(client): async def test_post_multipart(client): """ """ - f = 'tests/testdata/multipart.dat' + file = 'tests/testdata/multipart.dat' - res = await client.post( - None, files={'test': ('filename', open(f, 'rb'), 'text/plain')}) + with open(file, 'rb') as f: + res = await client.post(None, + files={'test': ('filename', f, 'text/plain')}) - assert res["files"]["test"] == open(f, 'rb').read().decode("utf-8") + with open(file, 'rb') as f: + assert res["files"]["test"] == f.read().decode("utf-8") @pytest.mark.asyncio async def test_post_multipart_decorators(client): """ """ - f = 'tests/testdata/multipart.dat' - res = await client.post_multipart( - bytes('TEST1', 'utf-8'), bytes('TEST2', 'utf-8'), - ('filename', open(f, 'rb'), 'text/plain')) + file = 'tests/testdata/multipart.dat' + + with open(file, 'rb') as f: + res = await client.post_multipart(bytes('TEST1', 'utf-8'), + bytes('TEST2', 'utf-8'), + ('filename', f, 'text/plain')) assert res["files"]["part1"] == 'TEST1' assert res["files"]["part2"] == 'TEST2' - assert res["files"]["test"] == open(f, 'rb').read().decode("utf-8") + with open(file, 'rb') as f: + assert res["files"]["test"] == f.read().decode("utf-8") @pytest.mark.asyncio diff --git a/tests/httpbin_test.py b/tests/httpbin_test.py index 50ef8c0..3ec1aa0 100644 --- a/tests/httpbin_test.py +++ b/tests/httpbin_test.py @@ -206,30 +206,35 @@ def test_post_form(client): def test_post_multipart(client): """ """ - f = 'tests/testdata/multipart.dat' + file = 'tests/testdata/multipart.dat' - if client._backend() == 'requests': - m = MultipartEncoder( - fields={'test': ('filename', open(f, 'rb'), 'text/plain')}) - res = client.post(None, content=m.content_type, data=m) - else: - res = client.post( - None, files={'test': ('filename', open(f, 'rb'), 'text/plain')}) + with open(file, 'rb') as f: + if client._backend() == 'requests': + m = MultipartEncoder( + fields={'test': ('filename', f, 'text/plain')}) + res = client.post(None, content=m.content_type, data=m) + else: + res = client.post(None, + files={'test': ('filename', f, 'text/plain')}) - assert res["files"]["test"] == open(f, 'rb').read().decode("utf-8") + with open(file, 'rb') as f: + assert res["files"]["test"] == f.read().decode("utf-8") @pytest.mark.parametrize("client", pytest_params) def test_post_multipart_decorators(client): """ """ - f = 'tests/testdata/multipart.dat' - res = client.post_multipart('TEST1', 'TEST2', - ('filename', open(f, 'rb'), 'text/plain')) + file = 'tests/testdata/multipart.dat' + + with open(file, 'rb') as f: + res = client.post_multipart('TEST1', 'TEST2', + ('filename', f, 'text/plain')) assert res["files"]["part1"] == 'TEST1' assert res["files"]["part2"] == 'TEST2' - assert res["files"]["test"] == open(f, 'rb').read().decode("utf-8") + with open(file, 'rb') as f: + assert res["files"]["test"] == f.read().decode("utf-8") @pytest.mark.parametrize("client", pytest_params) diff --git a/tox.ini b/tox.ini index d5d1599..d293d40 100644 --- a/tox.ini +++ b/tox.ini @@ -55,7 +55,7 @@ deps = requests requests-toolbelt typing-extensions -commands = py.test -v --cov=decorest [] tests/decorators_tests.py +commands = py.test -v --cov=decorest [] tests/decorators_tests.py -W error [testenv:swaggerpetstore] docker = @@ -68,7 +68,7 @@ deps = typing-extensions httpx brotli -commands = py.test -v --cov=decorest [] tests/petstore_test.py +commands = py.test -v --cov=decorest [] tests/petstore_test.py -W error [testenv:httpbin] setenv = @@ -84,9 +84,11 @@ deps = httpx Pillow brotli -commands = py.test -v --cov=decorest [] tests/httpbin_test.py +commands = py.test -v --cov=decorest [] tests/httpbin_test.py -W error [testenv:asynchttpbin] +env = + PYTHONASYNCIODEBUG=1 docker = httpbin deps = @@ -99,7 +101,7 @@ deps = httpx Pillow brotli -commands = py.test -v --cov=decorest [] tests/httpbin_async_test.py -W error::RuntimeWarning -W error::UserWarning +commands = py.test -v --cov=decorest [] tests/httpbin_async_test.py -W error [docker:httpbin] image = kennethreitz/httpbin From 4ae347992dd2742d9d25a2db6964f1043a8aa639 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 26 Dec 2021 22:15:00 +0100 Subject: [PATCH 21/48] Updated changelog --- CHANGELOG.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0fa5a25..6c74ec3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,7 @@ * Added typing support and mypy tests * Added asyncio support through httpx * Fixed handling of multiple header decorators +* Treat warnings as errors in tests 0.0.7 (2021-11-27) From 608c92bb8753e2a1cfe2b614eb2c7f5dce6003cb Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 2 Jan 2022 13:33:57 +0100 Subject: [PATCH 22/48] Added authentication unit tests --- decorest/request.py | 3 ++- decorest/session.py | 6 ++++++ tests/decorators_tests.py | 34 ++++++++++++++++++++++++++++++++++ tox.ini | 2 ++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/decorest/request.py b/decorest/request.py index 8df3e2c..ee6c61c 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -216,7 +216,8 @@ def __init__(self, func: typing.Callable[..., LOG.debug('Request: {method} {request}'.format(method=self.http_method, request=self.req)) if auth: - self.kwargs['auth'] = auth + if self.rest_client._backend() == 'requests': + self.kwargs['auth'] = auth if request_timeout: self.kwargs['timeout'] = request_timeout if body_content: diff --git a/decorest/session.py b/decorest/session.py index 324ca7b..5e6a346 100644 --- a/decorest/session.py +++ b/decorest/session.py @@ -59,6 +59,9 @@ def __getattr__(self, name: str) -> typing.Any: if name == '_close': return self.__session.close + if name == '_auth': + return self.__session.auth + def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: kwargs['__session'] = self.__session return getattr(self.__client, name)(*args, **kwargs) @@ -99,6 +102,9 @@ def __getattr__(self, name: str) -> typing.Any: if name == '_close': return self.__session.aclose + if name == '_auth': + return self.__session.auth + async def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: kwargs['__session'] = self.__session diff --git a/tests/decorators_tests.py b/tests/decorators_tests.py index b5f4f63..3129eac 100644 --- a/tests/decorators_tests.py +++ b/tests/decorators_tests.py @@ -18,6 +18,9 @@ import pytest import functools +from requests.auth import HTTPBasicAuth as r_HTTPBasicAuth +from httpx import BasicAuth as x_HTTPBasicAuth + from decorest import RestClient, HttpMethod from decorest import GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS from decorest import accept, content, endpoint, form, header, query, stream @@ -209,3 +212,34 @@ def test_introspection() -> None: if '__decorest__' in d: del d['__decorest__'] assert d == DogClient.plain_stream_range.__dict__ + + +def test_authentication_settings() -> None: + """ + Tests if authentication is properly configured. + """ + + r_client = DogClient(backend='requests') + assert r_client._auth() is None + r_client._set_auth(r_HTTPBasicAuth('username', 'password')) + assert r_client._auth() == r_HTTPBasicAuth('username', 'password') + + r_client_auth = DogClient(backend='requests', + auth=r_HTTPBasicAuth('username', 'password')) + assert r_client_auth._auth() == r_HTTPBasicAuth('username', 'password') + r_session_auth = r_client_auth._session() + assert r_session_auth._auth == r_HTTPBasicAuth('username', 'password') + + x_client = DogClient(backend='httpx') + assert x_client._auth() is None + x_client._set_auth(x_HTTPBasicAuth('username', 'password')) + assert x_client._auth()._auth_header == \ + x_HTTPBasicAuth('username', 'password')._auth_header + + x_client_auth = DogClient(backend='httpx', + auth=x_HTTPBasicAuth('username', 'password')) + assert x_client_auth._auth()._auth_header == \ + x_HTTPBasicAuth('username', 'password')._auth_header + x_session_auth = x_client_auth._session() + assert x_session_auth._auth._auth_header == \ + x_HTTPBasicAuth('username', 'password')._auth_header diff --git a/tox.ini b/tox.ini index d293d40..00469b3 100644 --- a/tox.ini +++ b/tox.ini @@ -55,6 +55,8 @@ deps = requests requests-toolbelt typing-extensions + httpx + httpx_auth commands = py.test -v --cov=decorest [] tests/decorators_tests.py -W error [testenv:swaggerpetstore] From 34a42ed1112d6447bd1d6417cdded0fb680623e4 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 2 Jan 2022 14:39:02 +0100 Subject: [PATCH 23/48] Fixed authentication issues between requests and httpx --- README.rst | 12 ++++++++++-- decorest/decorators.py | 6 +++++- decorest/request.py | 38 +++++++++++++++++++++++-------------- tests/httpbin_async_test.py | 12 ++++-------- tests/httpbin_test.py | 10 ++++++++-- 5 files changed, 51 insertions(+), 27 deletions(-) diff --git a/README.rst b/README.rst index d99da04..50f4952 100644 --- a/README.rst +++ b/README.rst @@ -94,7 +94,7 @@ To select a specific backend, simply pass it's name to the constructor of the cl If no backend is provided, requests_ is used by default. The client usage is largely independent of the backend, however there some minor differences in handling streams -and multipart messages, please consult tests in `httpbin test suite`_. +and multipart messages, please consult tests in `httpbin test suite`_ and `httpx compatibility guide`_. Decorators ---------- @@ -486,7 +486,7 @@ Authentication Since authentication is highly specific to actual invocation of the REST API, and not to it's specification, there is not decorator for authentication, but instead an authentication object (compatible with `requests_` -authentication mechanism) can be set in the client object using +or `httpx_` authentication mechanism) can be set in the client object using :py:`_set_auth()` method, for example: .. code-block:: python @@ -500,6 +500,13 @@ authentication mechanism) can be set in the client object using The authentication object will be used in both regular API calls, as well as when using sessions. +Furthermore, the `auth` object can be also passed to the client +constructor, e.g.: + +.. code-block:: python + + client = DogClient(backend='httpx', auth=httpx.BasicAuth('user', 'password')) + Error handling -------------- @@ -639,3 +646,4 @@ limitations under the License. .. _`httpbin test suite`: https://github.com/bkryza/decorest/blob/master/tests/httpbin_test.py .. _tox: https://github.com/tox-dev/tox .. _tox-docker: https://github.com/tox-dev/tox-docker +.. _httpx compatibility guide: https://www.python-httpx.org/compatibility/ \ No newline at end of file diff --git a/decorest/decorators.py b/decorest/decorators.py index 076fc33..b845eee 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -254,6 +254,7 @@ def __dispatch(self, http_request: HttpRequest) -> typing.Any: method = http_request.http_method.value[0].lower() ctx = http_request.execution_context + return methodcaller(method, http_request.req, **http_request.kwargs)(ctx) @@ -275,7 +276,10 @@ async def __dispatch_async(self, http_request: HttpRequest) -> typing.Any: import httpx if not isinstance(http_request.execution_context, httpx.AsyncClient): - async with httpx.AsyncClient() as client: + custom_kwargs = dict() + if http_request.rest_client.auth is not None: + custom_kwargs['auth'] = http_request.rest_client.auth + async with httpx.AsyncClient(**custom_kwargs) as client: return await client.request(method.upper(), http_request.req, **http_request.kwargs) else: diff --git a/decorest/request.py b/decorest/request.py index ee6c61c..65057b6 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -215,9 +215,31 @@ def __init__(self, func: typing.Callable[..., LOG.debug('Request: {method} {request}'.format(method=self.http_method, request=self.req)) - if auth: + + # If '__session' was passed in the kwargs, execute this request + # using the session context, otherwise execute directly via the + # requests or httpx module + if self.session: + self.execution_context = self.session + else: if self.rest_client._backend() == 'requests': - self.kwargs['auth'] = auth + self.execution_context = requests + else: + import httpx + self.execution_context = httpx + + if auth: + self.kwargs['auth'] = auth + + try: + import httpx + if isinstance(self.execution_context, + (httpx.Client, httpx.AsyncClient)): + # httpx does not allow 'auth' parameter on session requests + del self.kwargs['auth'] + except ImportError: + pass + if request_timeout: self.kwargs['timeout'] = request_timeout if body_content: @@ -244,18 +266,6 @@ def __init__(self, func: typing.Callable[..., if header_parameters: self.kwargs['headers'] = dict(header_parameters.items()) - # If '__session' was passed in the kwargs, execute this request - # using the session context, otherwise execute directly via the - # requests or httpx module - if self.session: - self.execution_context = self.session - else: - if self.rest_client._backend() == 'requests': - self.execution_context = requests - else: - import httpx - self.execution_context = httpx - def __validate_decor(self, decor: str, kwargs: ArgsDict, cls: typing.Type[typing.Any]) -> None: """ diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index 0a24174..3598ce6 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -24,14 +24,12 @@ import json import httpx +from httpx import BasicAuth from requests.structures import CaseInsensitiveDict from decorest import __version__, HttpStatus, HTTPErrorWrapper from requests import cookies -from requests.exceptions import ReadTimeout -from requests.auth import HTTPBasicAuth, HTTPDigestAuth -from requests_toolbelt.multipart.encoder import MultipartEncoder import xml.etree.ElementTree as ET sys.path.append(os.path.dirname(os.path.realpath(__file__)) + "/../examples") @@ -59,8 +57,8 @@ def basic_auth_client(): client = HttpBinAsyncClient("http://{host}:{port}".format(host=host, port=port), - backend='httpx') - client._set_auth(HTTPBasicAuth('user', 'password')) + backend='httpx', + auth=BasicAuth('user', 'password')) return client @@ -482,7 +480,6 @@ async def test_basic_auth(client, basic_auth_client): async def test_basic_auth_with_session(client, basic_auth_client): """ """ - res = None with basic_auth_client._session() as s: res = await s.basic_auth('user', 'password') @@ -495,8 +492,7 @@ async def test_hidden_basic_auth(client): """ res = await client.hidden_basic_auth('user', 'password', - auth=HTTPBasicAuth( - 'user', 'password')) + auth=BasicAuth('user', 'password')) assert res['authenticated'] is True diff --git a/tests/httpbin_test.py b/tests/httpbin_test.py index 3ec1aa0..5d25443 100644 --- a/tests/httpbin_test.py +++ b/tests/httpbin_test.py @@ -32,6 +32,7 @@ sys.path.append(os.path.dirname(os.path.realpath(__file__)) + "/../examples") from httpbin.httpbin_client import HttpBinClient, parse_image +from httpx import BasicAuth def client(backend: str) -> HttpBinClient: @@ -50,9 +51,14 @@ def basic_auth_client(backend): host = os.environ["HTTPBIN_HOST"] port = os.environ["HTTPBIN_80_TCP_PORT"] + if backend == 'requests': + auth = HTTPBasicAuth('user', 'password') + else: + auth = BasicAuth('user', 'password') + client = HttpBinClient("http://{host}:{port}".format(host=host, port=port), - backend=backend) - client._set_auth(HTTPBasicAuth('user', 'password')) + backend=backend, + auth=auth) return client From 4f4c5de0339fa7f05e01ce08898d1846c1811d39 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 2 Jan 2022 18:43:25 +0100 Subject: [PATCH 24/48] Make httpx backed automatically follow redirects --- decorest/decorators.py | 11 +++++++++-- decorest/request.py | 25 ++++++++++++++++--------- tests/httpbin_async_test.py | 26 +++++--------------------- 3 files changed, 30 insertions(+), 32 deletions(-) diff --git a/decorest/decorators.py b/decorest/decorators.py index b845eee..9f931e7 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -192,11 +192,18 @@ async def call_async(self, func: typing.Callable[..., typing.Any], if http_request.http_method == HttpMethod.GET \ and http_request.is_stream: del kwargs['stream'] + follow_redirects = True + if 'follow_redirects' in http_request.kwargs: + follow_redirects = http_request.kwargs['follow_redirects'] + del http_request.kwargs['follow_redirects'] + req = http_request.execution_context.build_request( 'GET', http_request.req, **http_request.kwargs) - result = await http_request.execution_context.send(req, - stream=True) + result = await http_request.\ + execution_context.send(req, + stream=True, + follow_redirects=follow_redirects) else: if http_request.http_method == HttpMethod.POST \ and http_request.is_multipart_request: diff --git a/decorest/request.py b/decorest/request.py index 65057b6..a04aedb 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -231,15 +231,6 @@ def __init__(self, func: typing.Callable[..., if auth: self.kwargs['auth'] = auth - try: - import httpx - if isinstance(self.execution_context, - (httpx.Client, httpx.AsyncClient)): - # httpx does not allow 'auth' parameter on session requests - del self.kwargs['auth'] - except ImportError: - pass - if request_timeout: self.kwargs['timeout'] = request_timeout if body_content: @@ -266,6 +257,22 @@ def __init__(self, func: typing.Callable[..., if header_parameters: self.kwargs['headers'] = dict(header_parameters.items()) + try: + import httpx + if isinstance(self.execution_context, + (httpx.Client, httpx.AsyncClient)): + # httpx does not allow 'auth' parameter on session requests + if 'auth' in self.kwargs: + del self.kwargs['auth'] + if 'follow_redirects' not in self.kwargs: + self.kwargs['follow_redirects'] = True + + if self.execution_context is httpx: + if 'follow_redirects' not in self.kwargs: + self.kwargs['follow_redirects'] = True + except ImportError: + pass + def __validate_decor(self, decor: str, kwargs: ArgsDict, cls: typing.Type[typing.Any]) -> None: """ diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index 3598ce6..b4ed03e 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -394,11 +394,7 @@ async def test_cookies(client): async def test_cookies_set(client): """ """ - res = await client.cookies_set(query={ - "cookie1": "A", - "cookie2": "B" - }, - follow_redirects=True) + res = await client.cookies_set(query={"cookie1": "A", "cookie2": "B"}) assert res["cookies"]["cookie1"] == "A" assert res["cookies"]["cookie2"] == "B" @@ -410,11 +406,7 @@ async def test_cookies_session(client): """ s = client._async_session() pprint.pprint(type(s)) - res = await s.cookies_set(query={ - "cookie1": "A", - "cookie2": "B" - }, - follow_redirects=True) + res = await s.cookies_set(query={"cookie1": "A", "cookie2": "B"}) assert res["cookies"]["cookie1"] == "A" assert res["cookies"]["cookie2"] == "B" @@ -433,11 +425,7 @@ async def test_cookies_session_with_contextmanager(client): """ async with client._async_session() as s: s._requests_session.verify = False - res = await s.cookies_set(query={ - "cookie1": "A", - "cookie2": "B" - }, - follow_redirects=True) + res = await s.cookies_set(query={"cookie1": "A", "cookie2": "B"}) assert res["cookies"]["cookie1"] == "A" assert res["cookies"]["cookie2"] == "B" @@ -452,12 +440,8 @@ async def test_cookies_session_with_contextmanager(client): async def test_cookies_delete(client): """ """ - await client.cookies_set(query={ - "cookie1": "A", - "cookie2": "B" - }, - follow_redirects=True) - await client.cookies_delete(query={"cookie1": None}, follow_redirects=True) + await client.cookies_set(query={"cookie1": "A", "cookie2": "B"}) + await client.cookies_delete(query={"cookie1": None}) res = await client.cookies() assert "cookie1" not in res["cookies"] From 11637d8149b0f3ca11b23c8499979a5fde2cd333 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 2 Jan 2022 19:26:28 +0100 Subject: [PATCH 25/48] Renamed _requests_session to _backend_session --- README.rst | 6 +++--- decorest/session.py | 4 ++++ tests/httpbin_async_test.py | 2 +- tests/httpbin_test.py | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 50f4952..1b6dac1 100644 --- a/README.rst +++ b/README.rst @@ -471,12 +471,12 @@ class. If some additional customization of the session is required, the underlying `requests session`_ object can be retrieved from decorest_ session object -using :py:`_requests_session` attribute: +using :py:`_backend_session` attribute: .. code-block:: python with client._session() as s: - s._requests_session.verify = '/path/to/cert.pem' + s._backend_session.verify = '/path/to/cert.pem' s.list_subbreeds('hound') s.list_subbreeds('husky') @@ -493,7 +493,7 @@ or `httpx_` authentication mechanism) can be set in the client object using client._set_auth(HTTPBasicAuth('user', 'password')) with client._session() as s: - s._requests_session.verify = '/path/to/cert.pem' + s._backend_session.verify = '/path/to/cert.pem' s.list_subbreeds('hound') s.list_subbreeds('husky') diff --git a/decorest/session.py b/decorest/session.py index 5e6a346..e80e7d5 100644 --- a/decorest/session.py +++ b/decorest/session.py @@ -50,6 +50,10 @@ def __exit__(self, *args: typing.Any) -> None: def __getattr__(self, name: str) -> typing.Any: """Forward any method invocation to actual client with session.""" + if name == '_backend_session': + return self.__session + + # deprecated if name == '_requests_session': return self.__session diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index b4ed03e..430d569 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -424,7 +424,7 @@ async def test_cookies_session_with_contextmanager(client): """ """ async with client._async_session() as s: - s._requests_session.verify = False + s._backend_session.verify = False res = await s.cookies_set(query={"cookie1": "A", "cookie2": "B"}) assert res["cookies"]["cookie1"] == "A" diff --git a/tests/httpbin_test.py b/tests/httpbin_test.py index 5d25443..c38b370 100644 --- a/tests/httpbin_test.py +++ b/tests/httpbin_test.py @@ -451,7 +451,7 @@ def test_cookies_session_with_contextmanager(client): """ """ with client._session() as s: - s._requests_session.verify = False + s._backend_session.verify = False res = s.cookies_set(query={"cookie1": "A", "cookie2": "B"}) assert res["cookies"]["cookie1"] == "A" From 578ddf06f7bee07b0cfd4756dd4d736b701ef1bd Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Mon, 3 Jan 2022 18:48:30 +0100 Subject: [PATCH 26/48] Added backend decorator --- decorest/__init__.py | 4 ++-- decorest/client.py | 13 +++++++------ decorest/decorator_utils.py | 7 ++++++- decorest/decorators.py | 19 ++++++++++++++++++- examples/httpbin/httpbin_async_client.py | 3 ++- tests/decorators_tests.py | 8 ++++++-- tests/httpbin_async_test.py | 4 +--- 7 files changed, 42 insertions(+), 16 deletions(-) diff --git a/decorest/__init__.py b/decorest/__init__.py index 7552395..6bb9272 100644 --- a/decorest/__init__.py +++ b/decorest/__init__.py @@ -23,7 +23,7 @@ from .POST import POST from .PUT import PUT from .client import RestClient -from .decorators import accept, body, content, endpoint, form, header +from .decorators import accept, backend, body, content, endpoint, form, header from .decorators import multipart, on, query, stream, timeout from .errors import HTTPErrorWrapper from .request import HttpRequest @@ -33,7 +33,7 @@ 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'RestClient', 'HTTPErrorWrapper', 'HttpMethod', 'HttpStatus', 'HttpRequest', 'query', 'body', 'header', 'on', 'accept', 'content', 'endpoint', 'timeout', - 'stream', 'form', 'multipart' + 'stream', 'form', 'multipart', 'backend' ] __version__ = "0.1.0" diff --git a/decorest/client.py b/decorest/client.py index 694af1b..80b49e0 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -22,7 +22,7 @@ import typing import urllib.parse -from .decorator_utils import get_decor +from .decorator_utils import get_backend_decor, get_endpoint_decor from .session import RestClientAsyncSession, RestClientSession from .types import AuthTypes, Backends from .utils import normalize_url @@ -33,13 +33,11 @@ class RestClient: def __init__(self, endpoint: typing.Optional[str] = None, auth: typing.Optional[AuthTypes] = None, - backend: Backends = 'requests'): + backend: typing.Optional[Backends] = None): """Initialize the client with optional endpoint.""" - self.endpoint = str(get_decor(self, 'endpoint')) + self.endpoint = endpoint or get_endpoint_decor(self) self.auth = auth - self._set_backend(backend) - if endpoint is not None: - self.endpoint = endpoint + self._set_backend(backend or get_backend_decor(self) or 'requests') def _session(self) -> RestClientSession: """ @@ -115,6 +113,9 @@ def build_request(self, path_components: typing.List[str]) -> str: """ LOG.debug("Building request from path tokens: %s", path_components) + if not self.endpoint: + raise ValueError("Server endpoint was not provided.") + req = urllib.parse.urljoin(normalize_url(self.endpoint), "/".join(path_components)) diff --git a/decorest/decorator_utils.py b/decorest/decorator_utils.py index 332115f..10f08a6 100644 --- a/decorest/decorator_utils.py +++ b/decorest/decorator_utils.py @@ -20,7 +20,7 @@ from requests.structures import CaseInsensitiveDict -from .types import HeaderDict, HttpMethod +from .types import Backends, HeaderDict, HttpMethod from .utils import merge_dicts, merge_header_dicts DECOR_KEY = '__decorest__' @@ -157,3 +157,8 @@ def get_body_decor(t: typing.Any) -> typing.Optional[typing.Any]: def get_endpoint_decor(t: typing.Any) -> typing.Optional[str]: """Return endpoint decor value.""" return typing.cast(typing.Optional[str], get_decor(t, 'endpoint')) + + +def get_backend_decor(t: typing.Any) -> Backends: + """Return backend decor value.""" + return typing.cast(Backends, get_decor(t, 'backend')) diff --git a/decorest/decorators.py b/decorest/decorators.py index 9f931e7..ace647a 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -31,7 +31,7 @@ from .decorator_utils import set_decor, set_header_decor from .errors import HTTPErrorWrapper from .request import HttpRequest -from .types import HttpMethod, HttpStatus, TDecor +from .types import Backends, HttpMethod, HttpStatus, TDecor def on(status: typing.Union[types.ellipsis, int], @@ -176,6 +176,23 @@ def stream(t: TDecor) -> TDecor: return t +def backend(value: Backends) -> typing.Callable[[TDecor], TDecor]: + """ + Specify default backend for the client. + + Without this decorator, default backend is 'requests'. + This decorator is only applicable to client classes. + """ + def backend_decorator(t: TDecor) -> TDecor: + if not inspect.isclass(t): + raise TypeError("@backend decorator can only be " + "applied to classes.") + set_decor(t, 'backend', value) + return typing.cast(TDecor, t) + + return backend_decorator + + class HttpMethodDecorator: """Abstract decorator for HTTP method decorators.""" def __init__(self, path: str): diff --git a/examples/httpbin/httpbin_async_client.py b/examples/httpbin/httpbin_async_client.py index 9acdfca..a752a13 100644 --- a/examples/httpbin/httpbin_async_client.py +++ b/examples/httpbin/httpbin_async_client.py @@ -24,7 +24,7 @@ from decorest import DELETE, GET, PATCH, POST, PUT from decorest import HttpStatus, RestClient from decorest import __version__, accept, body, content, endpoint, form -from decorest import header, multipart, on, query, stream, timeout +from decorest import backend, header, multipart, on, query, stream, timeout def repeatdecorator(f): @@ -51,6 +51,7 @@ def parse_image(response): @header('user-agent', 'decorest/{v}'.format(v=__version__)) @accept('application/json') @endpoint('http://httpbin.org') +@backend('httpx') class HttpBinAsyncClient(RestClient): """Client to HttpBin service (httpbin.org).""" @GET('ip') diff --git a/tests/decorators_tests.py b/tests/decorators_tests.py index 3129eac..ad18657 100644 --- a/tests/decorators_tests.py +++ b/tests/decorators_tests.py @@ -23,8 +23,9 @@ from decorest import RestClient, HttpMethod from decorest import GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS -from decorest import accept, content, endpoint, form, header, query, stream -from decorest.decorator_utils import get_decor, get_header_decor, \ +from decorest import accept, backend, content, endpoint, form, header +from decorest import query, stream +from decorest.decorator_utils import get_backend_decor, get_header_decor, \ get_endpoint_decor, get_form_decor, get_query_decor, \ get_stream_decor, get_on_decor, get_method_decor @@ -33,6 +34,7 @@ @content('application/xml') @header('X-Auth-Key', 'ABCD') @endpoint('https://dog.ceo/') +@backend('requests') class DogClient(RestClient): """DogClient client""" @GET('breed/{breed_name}/list') @@ -147,6 +149,8 @@ class and methods. 'size': 'size' } + assert get_backend_decor(DogClient) == 'requests' + def test_endpoint_decorator() -> None: """ diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index 430d569..62899ee 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -45,8 +45,7 @@ def client() -> HttpBinAsyncClient: port = os.environ["HTTPBIN_80_TCP_PORT"] return HttpBinAsyncClient("http://{host}:{port}".format(host=host, - port=port), - backend='httpx') + port=port)) @pytest.fixture @@ -57,7 +56,6 @@ def basic_auth_client(): client = HttpBinAsyncClient("http://{host}:{port}".format(host=host, port=port), - backend='httpx', auth=BasicAuth('user', 'password')) return client From 6e139d3aa97ce4c714ee2935ed474109f3d32ce9 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Mon, 3 Jan 2022 18:52:00 +0100 Subject: [PATCH 27/48] Updated README with backend decorator description --- README.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.rst b/README.rst index 1b6dac1..9a62d93 100644 --- a/README.rst +++ b/README.rst @@ -440,6 +440,22 @@ object which then can be accessed for instance using :py:`iter_content()` method content.append(b) +@backend +~~~~~~~~ +Specifies the default backend to use by the client, currently the only possible +values are `requests` (default) and `httpx`, e.g.: + +.. code-block:: python + + @endpoint('https://dog.ceo/api') + @backend('httpx') + class DogClient(RestClient): + """List all sub-breeds""" + ... + +The backend provided in the constructor arguments when creating client instance has precedence +over the value provided in this decorator. This decorator can only be applied to classes. + Sessions -------- From f2d27ebf90de570b8ff75fa8bff7065185feeddc Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Tue, 4 Jan 2022 00:09:58 +0100 Subject: [PATCH 28/48] Fixed swagger petstore example class decorators --- examples/swagger_petstore/petstore_client.py | 8 ++++---- examples/swagger_petstore/petstore_client_with_typing.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/swagger_petstore/petstore_client.py b/examples/swagger_petstore/petstore_client.py index 1e1f5df..5f58628 100644 --- a/examples/swagger_petstore/petstore_client.py +++ b/examples/swagger_petstore/petstore_client.py @@ -29,10 +29,6 @@ from decorest import accept, body, content, endpoint, header, on, query -@header('user-agent', 'decorest/{v}'.format(v=__version__)) -@content('application/json') -@accept('application/json') -@endpoint('http://petstore.example.com') class PetAPI(RestClient): """Everything about your Pets.""" @POST('pet') @@ -139,5 +135,9 @@ def delete_user(self, username): """Delete user.""" +@header('user-agent', 'decorest/{v}'.format(v=__version__)) +@content('application/json') +@accept('application/json') +@endpoint('http://petstore.example.com') class PetstoreClient(PetAPI, StoreAPI, UserAPI): """Swagger Petstore client.""" diff --git a/examples/swagger_petstore/petstore_client_with_typing.py b/examples/swagger_petstore/petstore_client_with_typing.py index 5830b70..42253fe 100644 --- a/examples/swagger_petstore/petstore_client_with_typing.py +++ b/examples/swagger_petstore/petstore_client_with_typing.py @@ -34,10 +34,6 @@ JsonDictType = typing.Dict[str, typing.Any] -@header('user-agent', 'decorest/{v}'.format(v=__version__)) -@content('application/json') -@accept('application/json') -@endpoint('http://petstore.example.com') class PetAPI(RestClient): """Everything about your Pets.""" @POST('pet') @@ -148,6 +144,10 @@ def delete_user(self, username: str) -> None: """Delete user.""" +@header('user-agent', 'decorest/{v}'.format(v=__version__)) +@content('application/json') +@accept('application/json') +@endpoint('http://petstore.example.com') class PetstoreClientWithTyping(PetAPI, StoreAPI, UserAPI): """Swagger Petstore client.""" From 5422396b0ee99b2c69c3cf1d1c4b1a97c88e0bb9 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Thu, 6 Jan 2022 21:04:40 +0100 Subject: [PATCH 29/48] Fixed httpx tests and enabled passing special args to clients --- decorest/client.py | 158 ++++++++++++++++++++++++++++---------- decorest/decorators.py | 8 +- decorest/request.py | 101 +++++++++++++++--------- decorest/session.py | 41 ++++++---- decorest/types.py | 2 +- tests/decorators_tests.py | 44 ++++++++++- tests/httpbin_test.py | 18 ++++- 7 files changed, 272 insertions(+), 100 deletions(-) diff --git a/decorest/client.py b/decorest/client.py index 80b49e0..f52ed0d 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -24,22 +24,57 @@ from .decorator_utils import get_backend_decor, get_endpoint_decor from .session import RestClientAsyncSession, RestClientSession -from .types import AuthTypes, Backends +from .types import ArgsDict, AuthTypes, Backends from .utils import normalize_url class RestClient: - """Base class for decorest REST clients.""" + """ + Base class for decorest REST clients. + + Method naming conventions for this class: + - rule #1: do not restrict user's API + - internal methods should be prefixed with '_' + - user accessible methods should be suffixed with '_' + """ + __backend: Backends + __endpoint: str + __client_args: typing.Any + def __init__(self, endpoint: typing.Optional[str] = None, - auth: typing.Optional[AuthTypes] = None, - backend: typing.Optional[Backends] = None): + **kwargs: ArgsDict): """Initialize the client with optional endpoint.""" - self.endpoint = endpoint or get_endpoint_decor(self) - self.auth = auth - self._set_backend(backend or get_backend_decor(self) or 'requests') - - def _session(self) -> RestClientSession: + # First determine the preferred backend + backend = None + if 'backend' in kwargs: + backend = kwargs['backend'] + del kwargs['backend'] + self.__backend = backend or get_backend_decor(self) or 'requests' + + # Check if the client arguments contain endpoint value + self.__endpoint = endpoint or get_endpoint_decor(self) + + # Check if the other named arguments match the allowed arguments + # for specified backend + if self.__backend == 'requests': + import requests + valid_client_args = requests.Session.__attrs__ + elif self.__backend == 'httpx': + import httpx + import inspect + valid_client_args \ + = inspect.getfullargspec(httpx.Client.__init__).kwonlyargs + else: + raise ValueError(f'Invalid backend: {self._backend}') + + if not set(kwargs.keys()).issubset(set(valid_client_args)): + raise ValueError(f'Invalid named arguments passed to the client: ' + f'{set(valid_client_args) - set(kwargs.keys())}') + + self.__client_args = kwargs + + def session_(self, **kwargs) -> RestClientSession: """ Initialize RestClientSession session object. @@ -49,9 +84,9 @@ def _session(self) -> RestClientSession: Each valid API method defined in the API client can be called directly via the session object. """ - return RestClientSession(self) + return RestClientSession(self, **kwargs) - def _async_session(self) -> RestClientAsyncSession: + def async_session_(self, **kwargs) -> RestClientAsyncSession: """ Initialize RestClientAsyncSession session object. @@ -61,49 +96,66 @@ def _async_session(self) -> RestClientAsyncSession: Each valid API method defined in the API client can be called directly via the session object. """ - return RestClientAsyncSession(self) + return RestClientAsyncSession(self, **kwargs) - def _set_auth(self, auth: AuthTypes) -> None: + @property + def backend_(self) -> str: """ - Set a default authentication method for the client. + Get active backend. - Currently the object must be a proper subclass of - `requests.auth.AuthBase` class. + Returns the name of the active backend. """ - self.auth = auth + return self.__backend - def _auth(self) -> typing.Optional[AuthTypes]: + @property + def endpoint_(self) -> str: """ - Get authentication object. + Get server endpoint. - Returns the authentication object set for this client. + Returns the endpoint for the server, which was provided in the + class decorator or in the client constructor arguments. """ - return self.auth + return self.__endpoint - def _set_backend(self, backend: Backends) -> None: + @property + def client_args_(self) -> typing.Any: + """ + Get arguments provided to client. + + Returns the dictionary with arguments that will be passed + to session objects or requests. The dictionary keys depend + on the backend: + - for requests the valid keys are specified in: + requests.Session.__attrs__ + - for httpx the valid keys are specified in the + method: + https.Client.__init__ """ - Set preferred backend. + return self.__client_args - This method allows to select which backend should be used for - making actual HTTP[S] requests, currently supported are: - * requests (default) - * httpx + def _get_or_none(self, key: str) -> typing.Any: + if key in self.__client_args: + return self.__client_args[key] - The options should be passed as string. - """ - if backend not in ('requests', 'httpx'): - raise ValueError('{} backend not supported...'.format(backend)) + return None - self.backend = backend + def __getitem__(self, key: str) -> typing.Any: + """Return named client argument.""" + return self._get_or_none(key) - def _backend(self) -> str: - """ - Get active backend. + def __setitem__(self, key: str, value: typing.Any) -> None: + """Set named client argument.""" + self.__client_args[key] = value - Returns the name of the active backend. - """ - return self.backend + def set_auth_(self, auth: AuthTypes) -> None: + """Set authentication for the client.""" + self.__client_args['auth'] = auth + def auth_(self) -> typing.Any: + """Return the client authentication.""" + return self._get_or_none('auth') + + # TODO: make protected (or private) def build_request(self, path_components: typing.List[str]) -> str: """ Build request. @@ -113,10 +165,36 @@ def build_request(self, path_components: typing.List[str]) -> str: """ LOG.debug("Building request from path tokens: %s", path_components) - if not self.endpoint: + if not self.__endpoint: raise ValueError("Server endpoint was not provided.") - req = urllib.parse.urljoin(normalize_url(self.endpoint), + req = urllib.parse.urljoin(normalize_url(self.__endpoint), "/".join(path_components)) return req + + # here start the deprecated methods for compatibility + def _backend(self) -> str: + """ + Get active backend [deprecated]. + + Returns the name of the active backend. + """ + return self.__backend + + def _auth(self) -> AuthTypes: + """ + Get auth method if specified [deprecated]. + + Returns the authentication object provided in the arguments. + """ + return self.auth_() + + def _session(self) -> RestClientSession: + return self.session_() + + def _async_session(self) -> RestClientAsyncSession: + return self.async_session_() + + def _set_auth(self, auth: AuthTypes) -> None: + self.set_auth_(auth) diff --git a/decorest/decorators.py b/decorest/decorators.py index ace647a..0014dec 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -233,7 +233,7 @@ async def call_async(self, func: typing.Callable[..., typing.Any], except Exception as e: raise HTTPErrorWrapper(typing.cast(types.HTTPErrors, e)) - return http_request.handle(result) + return http_request.handle_response(result) def call(self, func: typing.Callable[..., typing.Any], *args: typing.Any, **kwargs: typing.Any) -> typing.Any: @@ -260,7 +260,7 @@ def call(self, func: typing.Callable[..., typing.Any], *args: typing.Any, except Exception as e: raise HTTPErrorWrapper(typing.cast(types.HTTPErrors, e)) - return http_request.handle(result) + return http_request.handle_response(result) def __dispatch(self, http_request: HttpRequest) -> typing.Any: """ @@ -301,8 +301,8 @@ async def __dispatch_async(self, http_request: HttpRequest) -> typing.Any: if not isinstance(http_request.execution_context, httpx.AsyncClient): custom_kwargs = dict() - if http_request.rest_client.auth is not None: - custom_kwargs['auth'] = http_request.rest_client.auth + if http_request.rest_client.auth_() is not None: + custom_kwargs['auth'] = http_request.rest_client.auth_() async with httpx.AsyncClient(**custom_kwargs) as client: return await client.request(method.upper(), http_request.req, **http_request.kwargs) diff --git a/decorest/request.py b/decorest/request.py index a04aedb..0f7fbc9 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -196,7 +196,7 @@ def __init__(self, func: typing.Callable[..., if multipart_parameters: self.is_multipart_request = True self.kwargs['files'] = multipart_parameters - elif self.rest_client._backend() == 'requests': + elif self.rest_client.backend_ == 'requests': from requests_toolbelt.multipart.encoder import MultipartEncoder self.is_multipart_request = \ 'data' in self.kwargs and \ @@ -222,7 +222,7 @@ def __init__(self, func: typing.Callable[..., if self.session: self.execution_context = self.session else: - if self.rest_client._backend() == 'requests': + if self.rest_client.backend_ == 'requests': self.execution_context = requests else: import httpx @@ -238,7 +238,7 @@ def __init__(self, func: typing.Callable[..., if isinstance(body_content, dict): body_content = json.dumps(body_content) - if self.rest_client._backend() == 'httpx': + if self.rest_client.backend_ == 'httpx': if isinstance(body_content, dict): self.kwargs['data'] = body_content else: @@ -267,52 +267,44 @@ def __init__(self, func: typing.Callable[..., if 'follow_redirects' not in self.kwargs: self.kwargs['follow_redirects'] = True + merge_dicts(self.kwargs, self.rest_client.client_args_) + if self.execution_context is httpx: if 'follow_redirects' not in self.kwargs: self.kwargs['follow_redirects'] = True except ImportError: pass - def __validate_decor(self, decor: str, kwargs: ArgsDict, - cls: typing.Type[typing.Any]) -> None: - """ - Ensure kwargs contain decor with specific type. + if self.rest_client.backend_ == 'requests': + self._normalize_for_requests(self.kwargs) + else: + self._normalize_for_httpx(self.kwargs) - Args: - decor(str): Name of the decorator - kwargs(dict): Named arguments passed to API call - cls(class): Expected type of decorator parameter + def _normalize_for_httpx(self, kwargs: ArgsDict): """ - if not isinstance(kwargs[decor], cls): - raise TypeError("{} value must be an instance of {}".format( - decor, cls.__name__)) + Normalize kwargs for httpx. - def __merge_args(self, args_dict: ArgsDict, - func: typing.Callable[..., typing.Any], decor: str) \ - -> ArgsDict: + Translates and converts argument names and values from + requests to httpx, e.g. + 'allow_redirects' -> 'follow_redirects' """ - Match named arguments from method call. + if 'allow_redirects' in kwargs: + kwargs['follow_redirects'] = kwargs['allow_redirects'] + del kwargs['allow_redirects'] - Args: - args_dict (dict): Function arguments dictionary - func (type): Decorated function - decor (str): Name of specific decorator (e.g. 'query') + def _normalize_for_requests(self, kwargs: ArgsDict): + """ + Normalize kwargs for requests. - Returns: - object: any value assigned to the name key + Translates and converts argument names and values from + requests to httpx, e.g. + 'follow_redirects' -> 'allow_redirects' """ - args_decor = get_decor(func, decor) - parameters = {} - if args_decor: - for arg, value in args_decor.items(): - if (isinstance(value, str)) \ - and arg in args_dict.keys(): - parameters[value] = args_dict[arg] - else: - parameters[arg] = value - return parameters + if 'follow_redirects' in kwargs: + kwargs['allow_redirects'] = kwargs['follow_redirects'] + del kwargs['follow_redirects'] - def handle(self, result: typing.Any) -> typing.Any: + def handle_response(self, result: typing.Any) -> typing.Any: """Handle result response.""" if self.on_handlers and result.status_code in self.on_handlers: # Use a registered handler for the returned status code @@ -342,3 +334,42 @@ def handle(self, result: typing.Any) -> typing.Any: return result.text return None + + def __validate_decor(self, decor: str, kwargs: ArgsDict, + cls: typing.Type[typing.Any]) -> None: + """ + Ensure kwargs contain decor with specific type. + + Args: + decor(str): Name of the decorator + kwargs(dict): Named arguments passed to API call + cls(class): Expected type of decorator parameter + """ + if not isinstance(kwargs[decor], cls): + raise TypeError("{} value must be an instance of {}".format( + decor, cls.__name__)) + + def __merge_args(self, args_dict: ArgsDict, + func: typing.Callable[..., typing.Any], decor: str) \ + -> ArgsDict: + """ + Match named arguments from method call. + + Args: + args_dict (dict): Function arguments dictionary + func (type): Decorated function + decor (str): Name of specific decorator (e.g. 'query') + + Returns: + object: any value assigned to the name key + """ + args_decor = get_decor(func, decor) + parameters = {} + if args_decor: + for arg, value in args_decor.items(): + if (isinstance(value, str)) \ + and arg in args_dict.keys(): + parameters[value] = args_dict[arg] + else: + parameters[arg] = value + return parameters \ No newline at end of file diff --git a/decorest/session.py b/decorest/session.py index e80e7d5..98c56bb 100644 --- a/decorest/session.py +++ b/decorest/session.py @@ -18,27 +18,30 @@ import asyncio import typing +from .utils import merge_dicts + if typing.TYPE_CHECKING: from .client import RestClient class RestClientSession: """Wrap a `requests` session for specific API client.""" - def __init__(self, client: 'RestClient') -> None: + def __init__(self, client: 'RestClient', **kwargs) -> None: """Initialize the session instance with a specific API client.""" self.__client: 'RestClient' = client # Create a session of type specific for given backend - if client._backend() == 'requests': + if self.__client.backend_ == 'requests': import requests from decorest.types import SessionTypes self.__session: SessionTypes = requests.Session() + for a in requests.Session.__attrs__: + if a in kwargs: + setattr(self.__session, a, kwargs[a]) else: import httpx - self.__session = httpx.Client() - - if self.__client.auth is not None: - self.__session.auth = self.__client.auth + args = merge_dicts(self.__client.client_args_, kwargs) + self.__session = httpx.Client(**args) def __enter__(self) -> 'RestClientSession': """Context manager initialization.""" @@ -48,23 +51,31 @@ def __exit__(self, *args: typing.Any) -> None: """Context manager destruction.""" self.__session.close() + def __getitem__(self, key: str) -> typing.Any: + """Return named client argument.""" + return self.__client._get_or_none(key) + + def __setitem__(self, key: str, value: typing.Any) -> None: + """Set named client argument.""" + self.__client[key] = value + def __getattr__(self, name: str) -> typing.Any: """Forward any method invocation to actual client with session.""" - if name == '_backend_session': + if name == 'backend_session_': return self.__session # deprecated if name == '_requests_session': return self.__session - if name == '_client': + if name == 'client_' or name == '_client': return self.__client if name == '_close': return self.__session.close - if name == '_auth': - return self.__session.auth + if name == 'auth_' or name == '_auth': + return self.__client['auth'] def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: kwargs['__session'] = self.__session @@ -75,16 +86,14 @@ def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: class RestClientAsyncSession: """Wrap a `requests` session for specific API client.""" - def __init__(self, client: 'RestClient') -> None: + def __init__(self, client: 'RestClient', **kwargs) -> None: """Initialize the session instance with a specific API client.""" self.__client: 'RestClient' = client # Create a session of type specific for given backend import httpx - self.__session = httpx.AsyncClient() - - if self.__client.auth is not None: - self.__session.auth = self.__client.auth + args = merge_dicts(self.__client.client_args_, kwargs) + self.__session = httpx.AsyncClient(**args) async def __aenter__(self) -> 'RestClientAsyncSession': """Context manager initialization.""" @@ -112,6 +121,8 @@ def __getattr__(self, name: str) -> typing.Any: async def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: kwargs['__session'] = self.__session + + # TODO: MERGE __client_args with kwargs assert asyncio.iscoroutinefunction(getattr(self.__client, name)) return await getattr(self.__client, name)(*args, **kwargs) diff --git a/decorest/types.py b/decorest/types.py index 0734661..438187a 100644 --- a/decorest/types.py +++ b/decorest/types.py @@ -55,7 +55,7 @@ class HttpStatus(DIntEnum): ArgsDict = typing.Dict[str, typing.Any] Backends = typing_extensions.Literal['requests', 'httpx'] -AuthTypes = typing.Union['requests.auth.AuthBase', 'httpx.Auth'] +AuthTypes = typing.Union['requests.auth.AuthBase', 'httpx.AuthTypes'] HeaderDict = typing.Mapping[str, typing.Union[str, typing.List[str]]] SessionTypes = typing.Union['requests.Session', 'httpx.Client'] HTTPErrors = typing.Union['requests.HTTPError', 'httpx.HTTPStatusError'] diff --git a/tests/decorators_tests.py b/tests/decorators_tests.py index ad18657..d197c2e 100644 --- a/tests/decorators_tests.py +++ b/tests/decorators_tests.py @@ -159,11 +159,11 @@ def test_endpoint_decorator() -> None: default_client = DogClient() - assert default_client.endpoint == 'https://dog.ceo/' + assert default_client.endpoint_ == 'https://dog.ceo/' custom_client = DogClient('http://dogceo.example.com') - assert custom_client.endpoint == 'http://dogceo.example.com' + assert custom_client.endpoint_ == 'http://dogceo.example.com' def test_introspection() -> None: @@ -222,15 +222,53 @@ def test_authentication_settings() -> None: """ Tests if authentication is properly configured. """ + r_client = DogClient(backend='requests') + assert r_client['auth'] is None + r_client['auth'] = r_HTTPBasicAuth('username', 'password') + assert r_client['auth'] == r_HTTPBasicAuth('username', 'password') + + r_client_auth = DogClient(backend='requests', + auth=r_HTTPBasicAuth('username', 'password')) + assert r_client_auth['auth'] == r_HTTPBasicAuth('username', 'password') + + r_session_auth = r_client_auth._session() + assert r_session_auth['auth'] == r_HTTPBasicAuth('username', 'password') + + x_client = DogClient(backend='httpx') + assert x_client['auth'] is None + x_client['auth'] = x_HTTPBasicAuth('username', 'password') + assert x_client['auth']._auth_header == \ + x_HTTPBasicAuth('username', 'password')._auth_header + + x_client_auth = DogClient(backend='httpx', + auth=x_HTTPBasicAuth('username', 'password')) + assert x_client_auth['auth']._auth_header == \ + x_HTTPBasicAuth('username', 'password')._auth_header + x_session_auth = x_client_auth._session() + assert x_session_auth._auth._auth_header == \ + x_HTTPBasicAuth('username', 'password')._auth_header + + +def test_authentication_settings_deprecated() -> None: + """ + Tests if authentication is properly configured. + """ r_client = DogClient(backend='requests') - assert r_client._auth() is None + assert r_client.auth_() is None r_client._set_auth(r_HTTPBasicAuth('username', 'password')) + assert r_client['auth'] == r_HTTPBasicAuth('username', 'password') + + assert r_client.auth_() == r_HTTPBasicAuth('username', 'password') + assert r_client._auth() == r_HTTPBasicAuth('username', 'password') r_client_auth = DogClient(backend='requests', auth=r_HTTPBasicAuth('username', 'password')) + assert r_client_auth.auth_() == r_HTTPBasicAuth('username', 'password') + assert r_client_auth._auth() == r_HTTPBasicAuth('username', 'password') + r_session_auth = r_client_auth._session() assert r_session_auth._auth == r_HTTPBasicAuth('username', 'password') diff --git a/tests/httpbin_test.py b/tests/httpbin_test.py index c38b370..b810c97 100644 --- a/tests/httpbin_test.py +++ b/tests/httpbin_test.py @@ -15,12 +15,14 @@ # limitations under the License. import pprint +import httpx import pytest import time import os import sys import json +import requests from requests.structures import CaseInsensitiveDict from decorest import __version__, HttpStatus, HTTPErrorWrapper @@ -72,7 +74,7 @@ def basic_auth_client(backend): ] client_httpx = client('httpx') -pytest_params.append(pytest.param(client_requests, id='httpx')) +pytest_params.append(pytest.param(client_httpx, id='httpx')) basic_auth_client_httpx = basic_auth_client('httpx') pytest_basic_auth_params.append( pytest.param(client_httpx, basic_auth_client_httpx, id='httpx')) @@ -234,7 +236,7 @@ def test_post_multipart_decorators(client): file = 'tests/testdata/multipart.dat' with open(file, 'rb') as f: - res = client.post_multipart('TEST1', 'TEST2', + res = client.post_multipart(b'TEST1', b'TEST2', ('filename', f, 'text/plain')) assert res["files"]["part1"] == 'TEST1' @@ -405,6 +407,18 @@ def test_absolute_redirect(client): assert res.endswith('/get') +@pytest.mark.parametrize("client", pytest_params) +def test_max_redirect(client): + """ + """ + with client.session_(max_redirects=1) as s: + with pytest.raises(HTTPErrorWrapper) as e: + s.redirect(5, on={302: lambda r: 'REDIRECTED'}) + + assert isinstance(e.value.wrapped, + (requests.TooManyRedirects, httpx.TooManyRedirects)) + + @pytest.mark.parametrize("client", pytest_params) def test_cookies(client): """ From 912d9d37c39586e06415ce98d0b67ff7e11afd37 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Thu, 6 Jan 2022 21:25:42 +0100 Subject: [PATCH 30/48] Fixed typing annotations --- decorest/client.py | 8 ++++---- decorest/request.py | 4 ++-- decorest/session.py | 6 ++++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/decorest/client.py b/decorest/client.py index f52ed0d..5b770c1 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -43,7 +43,7 @@ class RestClient: def __init__(self, endpoint: typing.Optional[str] = None, - **kwargs: ArgsDict): + **kwargs: typing.Any): """Initialize the client with optional endpoint.""" # First determine the preferred backend backend = None @@ -53,7 +53,7 @@ def __init__(self, self.__backend = backend or get_backend_decor(self) or 'requests' # Check if the client arguments contain endpoint value - self.__endpoint = endpoint or get_endpoint_decor(self) + self.__endpoint = endpoint or get_endpoint_decor(self) # type: ignore # Check if the other named arguments match the allowed arguments # for specified backend @@ -74,7 +74,7 @@ def __init__(self, self.__client_args = kwargs - def session_(self, **kwargs) -> RestClientSession: + def session_(self, **kwargs: ArgsDict) -> RestClientSession: """ Initialize RestClientSession session object. @@ -86,7 +86,7 @@ def session_(self, **kwargs) -> RestClientSession: """ return RestClientSession(self, **kwargs) - def async_session_(self, **kwargs) -> RestClientAsyncSession: + def async_session_(self, **kwargs: ArgsDict) -> RestClientAsyncSession: """ Initialize RestClientAsyncSession session object. diff --git a/decorest/request.py b/decorest/request.py index 0f7fbc9..321a6d8 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -280,7 +280,7 @@ def __init__(self, func: typing.Callable[..., else: self._normalize_for_httpx(self.kwargs) - def _normalize_for_httpx(self, kwargs: ArgsDict): + def _normalize_for_httpx(self, kwargs: ArgsDict) -> None: """ Normalize kwargs for httpx. @@ -292,7 +292,7 @@ def _normalize_for_httpx(self, kwargs: ArgsDict): kwargs['follow_redirects'] = kwargs['allow_redirects'] del kwargs['allow_redirects'] - def _normalize_for_requests(self, kwargs: ArgsDict): + def _normalize_for_requests(self, kwargs: ArgsDict) -> None: """ Normalize kwargs for requests. diff --git a/decorest/session.py b/decorest/session.py index 98c56bb..095d773 100644 --- a/decorest/session.py +++ b/decorest/session.py @@ -18,6 +18,7 @@ import asyncio import typing +from .types import ArgsDict from .utils import merge_dicts if typing.TYPE_CHECKING: @@ -26,7 +27,7 @@ class RestClientSession: """Wrap a `requests` session for specific API client.""" - def __init__(self, client: 'RestClient', **kwargs) -> None: + def __init__(self, client: 'RestClient', **kwargs: ArgsDict) -> None: """Initialize the session instance with a specific API client.""" self.__client: 'RestClient' = client @@ -86,7 +87,8 @@ def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: class RestClientAsyncSession: """Wrap a `requests` session for specific API client.""" - def __init__(self, client: 'RestClient', **kwargs) -> None: + def __init__(self, client: 'RestClient', **kwargs: ArgsDict) \ + -> None: """Initialize the session instance with a specific API client.""" self.__client: 'RestClient' = client From e991ffdb9b3535e81e002058b493a7997125b1e2 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Thu, 6 Jan 2022 23:55:11 +0100 Subject: [PATCH 31/48] Refactored build_request method --- decorest/client.py | 23 +++++++++++------------ decorest/decorators.py | 2 +- decorest/request.py | 2 +- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/decorest/client.py b/decorest/client.py index 5b770c1..029897d 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -147,21 +147,12 @@ def __setitem__(self, key: str, value: typing.Any) -> None: """Set named client argument.""" self.__client_args[key] = value - def set_auth_(self, auth: AuthTypes) -> None: - """Set authentication for the client.""" - self.__client_args['auth'] = auth - - def auth_(self) -> typing.Any: - """Return the client authentication.""" - return self._get_or_none('auth') - - # TODO: make protected (or private) - def build_request(self, path_components: typing.List[str]) -> str: + def build_path_(self, path_components: typing.List[str]) -> str: """ - Build request. + Build request path. Request is built by combining the endpoint with path - and query components. + components. """ LOG.debug("Building request from path tokens: %s", path_components) @@ -174,6 +165,14 @@ def build_request(self, path_components: typing.List[str]) -> str: return req # here start the deprecated methods for compatibility + def set_auth_(self, auth: AuthTypes) -> None: + """Set authentication for the client.""" + self.__client_args['auth'] = auth + + def auth_(self) -> typing.Any: + """Return the client authentication.""" + return self._get_or_none('auth') + def _backend(self) -> str: """ Get active backend [deprecated]. diff --git a/decorest/decorators.py b/decorest/decorators.py index 0014dec..91a39af 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -241,7 +241,7 @@ def call(self, func: typing.Callable[..., typing.Any], *args: typing.Any, http_request = HttpRequest(func, self.path_template, args, kwargs) try: - if http_request.rest_client._backend() == 'httpx' \ + if http_request.rest_client.backend_ == 'httpx' \ and http_request.http_method == HttpMethod.GET \ and http_request.is_stream: del kwargs['stream'] diff --git a/decorest/request.py b/decorest/request.py index 321a6d8..a6fdcca 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -189,7 +189,7 @@ def __init__(self, func: typing.Callable[..., else: pass # Build request from endpoint and query params - self.req = self.rest_client.build_request(req_path.split('/')) + self.req = self.rest_client.build_path_(req_path.split('/')) # Handle multipart parameters, either from decorators # or ones passed directly through kwargs From 870b0a40997143b8e071f7fcf3f68a7665a636f7 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Fri, 7 Jan 2022 00:23:07 +0100 Subject: [PATCH 32/48] Adde max_redirects test to async httpbin test --- tests/httpbin_async_test.py | 19 +++++++++++++++---- tox.ini | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index 62899ee..b36a37e 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -375,6 +375,17 @@ async def test_absolute_redirect(client): assert res.endswith('/get') +@pytest.mark.asyncio +async def test_max_redirect(client): + """ + """ + async with client.async_session_(max_redirects=1) as s: + with pytest.raises(HTTPErrorWrapper) as e: + await s.redirect(5, on={302: lambda r: 'REDIRECTED'}) + + assert isinstance(e.value.wrapped, httpx.TooManyRedirects) + + @pytest.mark.asyncio async def test_cookies(client): """ @@ -421,7 +432,7 @@ async def test_cookies_session(client): async def test_cookies_session_with_contextmanager(client): """ """ - async with client._async_session() as s: + async with client.async_session_() as s: s._backend_session.verify = False res = await s.cookies_set(query={"cookie1": "A", "cookie2": "B"}) @@ -510,7 +521,7 @@ async def test_stream_n(client): """ """ count = 0 - async with client._async_session() as s: + async with client.async_session_() as s: r = await s.stream_n(5) async for _ in r.aiter_lines(): count += 1 @@ -537,7 +548,7 @@ async def test_drip(client): """ """ content = [] - async with client._async_session() as s: + async with client.async_session_() as s: r = await s.drip(10, 5, 1, 200) async for b in r.aiter_raw(): content.append(b) @@ -551,7 +562,7 @@ async def test_range(client): """ content = [] - async with client._async_session() as s: + async with client.async_session_() as s: r = await s.range(128, 1, 2, header={"Range": "bytes=10-19"}) async for b in r.aiter_raw(): content.append(b) diff --git a/tox.ini b/tox.ini index 00469b3..af06dc9 100644 --- a/tox.ini +++ b/tox.ini @@ -90,7 +90,7 @@ commands = py.test -v --cov=decorest [] tests/httpbin_test.py -W error [testenv:asynchttpbin] env = - PYTHONASYNCIODEBUG=1 + PYTHONASYNCIODEBUG=0 docker = httpbin deps = From 94918931d2af9a597b5ae41250e53cfc45c07dfd Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Fri, 7 Jan 2022 11:27:43 +0100 Subject: [PATCH 33/48] Enabled accumulative code coverage in tox tests --- tox.ini | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index af06dc9..e890066 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] envlist = basic,swaggerpetstore,httpbin,flake8 + [testenv] deps = pytest @@ -57,7 +58,7 @@ deps = typing-extensions httpx httpx_auth -commands = py.test -v --cov=decorest [] tests/decorators_tests.py -W error +commands = py.test -v --cov=decorest tests/decorators_tests.py -W error [] [testenv:swaggerpetstore] docker = @@ -70,7 +71,7 @@ deps = typing-extensions httpx brotli -commands = py.test -v --cov=decorest [] tests/petstore_test.py -W error +commands = py.test -v --cov=decorest --cov-append tests/petstore_test.py -W error [] [testenv:httpbin] setenv = @@ -86,7 +87,7 @@ deps = httpx Pillow brotli -commands = py.test -v --cov=decorest [] tests/httpbin_test.py -W error +commands = py.test -v --cov=decorest --cov-append tests/httpbin_test.py -W error [] [testenv:asynchttpbin] env = @@ -103,7 +104,7 @@ deps = httpx Pillow brotli -commands = py.test -v --cov=decorest [] tests/httpbin_async_test.py -W error +commands = py.test -v --cov=decorest --cov-append tests/httpbin_async_test.py -W error [] [docker:httpbin] image = kennethreitz/httpbin From 1e7f78d6b003a5b1f764d7dca8e0217613b791a6 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Fri, 7 Jan 2022 15:34:03 +0100 Subject: [PATCH 34/48] Added api inheritance tests --- decorest/decorator_utils.py | 4 +- decorest/decorators.py | 8 +-- decorest/request.py | 38 +++++----- tests/api_inheritance_tests.py | 122 +++++++++++++++++++++++++++++++++ tox.ini | 3 +- 5 files changed, 149 insertions(+), 26 deletions(-) create mode 100644 tests/api_inheritance_tests.py diff --git a/decorest/decorator_utils.py b/decorest/decorator_utils.py index 10f08a6..356909f 100644 --- a/decorest/decorator_utils.py +++ b/decorest/decorator_utils.py @@ -27,7 +27,7 @@ DECOR_LIST = [ 'header', 'query', 'form', 'multipart', 'on', 'accept', 'content', - 'timeout', 'stream', 'body', 'endpoint' + 'timeout', 'stream', 'body', 'endpoint', 'backend' ] @@ -86,7 +86,7 @@ def get_decor(t: typing.Any, name: str) -> typing.Optional[typing.Any]: object: any value assigned to the name key """ - if hasattr(t, DECOR_KEY) and getattr(t, DECOR_KEY).get(name): + if hasattr(t, DECOR_KEY) and name in getattr(t, DECOR_KEY): return getattr(t, DECOR_KEY)[name] return None diff --git a/decorest/decorators.py b/decorest/decorators.py index 91a39af..2268d3f 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -229,7 +229,7 @@ async def call_async(self, func: typing.Callable[..., typing.Any], http_request.kwargs['headers'].pop( 'content-type', None) - result = await self.__dispatch_async(http_request) + result = await self._dispatch_async(http_request) except Exception as e: raise HTTPErrorWrapper(typing.cast(types.HTTPErrors, e)) @@ -256,13 +256,13 @@ def call(self, func: typing.Callable[..., typing.Any], *args: typing.Any, http_request.kwargs['headers'].pop( 'content-type', None) - result = self.__dispatch(http_request) + result = self._dispatch(http_request) except Exception as e: raise HTTPErrorWrapper(typing.cast(types.HTTPErrors, e)) return http_request.handle_response(result) - def __dispatch(self, http_request: HttpRequest) -> typing.Any: + def _dispatch(self, http_request: HttpRequest) -> typing.Any: """ Dispatch HTTP method based on HTTPMethod enum type. @@ -282,7 +282,7 @@ def __dispatch(self, http_request: HttpRequest) -> typing.Any: return methodcaller(method, http_request.req, **http_request.kwargs)(ctx) - async def __dispatch_async(self, http_request: HttpRequest) -> typing.Any: + async def _dispatch_async(self, http_request: HttpRequest) -> typing.Any: """ Dispatch HTTP method based on HTTPMethod enum type. diff --git a/decorest/request.py b/decorest/request.py index a6fdcca..85a70a5 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -14,7 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. """HTTP request wrapper.""" - import json import logging as LOG import numbers @@ -72,6 +71,7 @@ def __init__(self, func: typing.Callable[..., method=self.http_method)) self.rest_client = args[0] + args_dict = dict_from_args(func, *args) req_path = render_path(self.path_template, args_dict) self.session = None @@ -82,12 +82,12 @@ def __init__(self, func: typing.Callable[..., # Merge query parameters from common values for all method # invocations with arguments provided in the method # arguments - query_parameters = self.__merge_args(args_dict, func, 'query') - form_parameters = self.__merge_args(args_dict, func, 'form') - multipart_parameters = self.__merge_args(args_dict, func, 'multipart') + query_parameters = self._merge_args(args_dict, func, 'query') + form_parameters = self._merge_args(args_dict, func, 'form') + multipart_parameters = self._merge_args(args_dict, func, 'multipart') header_parameters = CaseInsensitiveDict( merge_dicts(get_header_decor(self.rest_client.__class__), - self.__merge_args(args_dict, func, 'header'))) + self._merge_args(args_dict, func, 'header'))) # Merge header parameters with default values, treat header # decorators with 2 params as default values only if they @@ -141,46 +141,46 @@ def __init__(self, func: typing.Callable[..., for decor in DECOR_LIST: if decor in self.kwargs: if decor == 'header': - self.__validate_decor(decor, self.kwargs, dict) + self._validate_decor(decor, self.kwargs, dict) header_parameters = merge_dicts( header_parameters, self.kwargs['header']) del self.kwargs['header'] elif decor == 'query': - self.__validate_decor(decor, self.kwargs, dict) + self._validate_decor(decor, self.kwargs, dict) query_parameters = merge_dicts(query_parameters, self.kwargs['query']) del self.kwargs['query'] elif decor == 'form': - self.__validate_decor(decor, self.kwargs, dict) + self._validate_decor(decor, self.kwargs, dict) form_parameters = merge_dicts(form_parameters, self.kwargs['form']) del self.kwargs['form'] elif decor == 'multipart': - self.__validate_decor(decor, self.kwargs, dict) + self._validate_decor(decor, self.kwargs, dict) multipart_parameters = merge_dicts( multipart_parameters, self.kwargs['multipart']) del self.kwargs['multipart'] elif decor == 'on': - self.__validate_decor(decor, self.kwargs, dict) + self._validate_decor(decor, self.kwargs, dict) self.on_handlers = merge_dicts(self.on_handlers, self.kwargs['on']) del self.kwargs['on'] elif decor == 'accept': - self.__validate_decor(decor, self.kwargs, str) + self._validate_decor(decor, self.kwargs, str) header_parameters['accept'] = self.kwargs['accept'] del self.kwargs['accept'] elif decor == 'content': - self.__validate_decor(decor, self.kwargs, str) + self._validate_decor(decor, self.kwargs, str) header_parameters['content-type'] \ = self.kwargs['content'] del self.kwargs['content'] elif decor == 'timeout': - self.__validate_decor(decor, self.kwargs, - numbers.Number) + self._validate_decor(decor, self.kwargs, + numbers.Number) request_timeout = self.kwargs['timeout'] del self.kwargs['timeout'] elif decor == 'stream': - self.__validate_decor(decor, self.kwargs, bool) + self._validate_decor(decor, self.kwargs, bool) self.is_stream = self.kwargs['stream'] del self.kwargs['stream'] elif decor == 'body': @@ -335,8 +335,8 @@ def handle_response(self, result: typing.Any) -> typing.Any: return None - def __validate_decor(self, decor: str, kwargs: ArgsDict, - cls: typing.Type[typing.Any]) -> None: + def _validate_decor(self, decor: str, kwargs: ArgsDict, + cls: typing.Type[typing.Any]) -> None: """ Ensure kwargs contain decor with specific type. @@ -349,8 +349,8 @@ def __validate_decor(self, decor: str, kwargs: ArgsDict, raise TypeError("{} value must be an instance of {}".format( decor, cls.__name__)) - def __merge_args(self, args_dict: ArgsDict, - func: typing.Callable[..., typing.Any], decor: str) \ + def _merge_args(self, args_dict: ArgsDict, + func: typing.Callable[..., typing.Any], decor: str) \ -> ArgsDict: """ Match named arguments from method call. diff --git a/tests/api_inheritance_tests.py b/tests/api_inheritance_tests.py new file mode 100644 index 0000000..016e284 --- /dev/null +++ b/tests/api_inheritance_tests.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2018-2021 Bartosz Kryza +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import json +import pprint +import typing + +import httpx +import pytest +import functools + +from requests.auth import HTTPBasicAuth as r_HTTPBasicAuth +from httpx import BasicAuth as x_HTTPBasicAuth + +from decorest import RestClient, on +from decorest import accept, backend, body, content, endpoint, header +from decorest import GET, PATCH, PUT + + +class APIOne(RestClient): + """API One client""" + @GET('stuff/{what}') + @on(200, lambda r: r.json()) + def get(self, what: str) -> typing.Any: + """Get what""" + + +class APITwo(RestClient): + """API One client""" + @PUT('stuff/{what}') + @body('body') + def put(self, sth: str, body: bytes) -> typing.Any: + """Put sth""" + + +class APIThree(RestClient): + """API Three client""" + @PATCH('stuff/{sth}') + @body('body') + @on(204, lambda _: True) + @on(..., lambda _: False) + def patch(self, sth: str, body: bytes) -> typing.Any: + """Patch sth""" + + +@accept('application/json') +@content('application/xml') +@header('X-Auth-Key', 'ABCD') +@endpoint('https://example.com') +@backend('httpx') +class InheritedClient(APITwo, APIOne, APIThree): + ... + + +def test_api_inheritance_properties() -> None: + """ + Check that API inheritance works. + """ + + client = InheritedClient() + + assert client.backend_ == 'httpx' + assert client.endpoint_ == 'https://example.com' + + client = InheritedClient(backend='requests', + endpoint="https://patches.example.com") + + assert client.backend_ == 'requests' + assert client.endpoint_ == 'https://patches.example.com' + + +def test_api_inheritance_basic(respx_mock) -> None: + """ + + """ + client = InheritedClient() + + expected = dict(id=1, name='thing1') + req = respx_mock.get("https://example.com/stuff/thing1")\ + .mock(return_value=httpx.Response(200, content=json.dumps(expected))) + + res = client.get('thing1') + + assert req.called is True + assert res == expected + + +def test_api_inheritance_custom_endpoint(respx_mock) -> None: + """ + + """ + client = InheritedClient(endpoint='https://patches.example.com') + + req = respx_mock.patch("https://patches.example.com/stuff/thing1")\ + .mock(return_value=httpx.Response(204)) + + res = client.patch('thing1', + body=json.loads('{"id": 1, "name": "thing2"}')) + + assert req.called is True + assert res is True + + req = respx_mock.patch("https://patches.example.com/stuff/thing1")\ + .mock(return_value=httpx.Response(500)) + + res = client.patch('thing1', + body=json.loads('{"id": 1, "notname": "thing2"}')) + + assert req.called is True + assert not res diff --git a/tox.ini b/tox.ini index e890066..639b255 100644 --- a/tox.ini +++ b/tox.ini @@ -58,7 +58,8 @@ deps = typing-extensions httpx httpx_auth -commands = py.test -v --cov=decorest tests/decorators_tests.py -W error [] + respx +commands = py.test -v --cov=decorest tests/decorators_tests.py tests/api_inheritance_tests.py -W error [] [testenv:swaggerpetstore] docker = From ec9351f1687ec101e25624bd6cf3d586144a4966 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Fri, 7 Jan 2022 20:04:36 +0100 Subject: [PATCH 35/48] Added playground folder to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3b92b62..2bcd456 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ parts/ sdist/ var/ wheels/ +playground/ *.egg-info/ .installed.cfg *.egg From d88076d5cdba58ca860800c7934d2a70751d2d18 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sat, 8 Jan 2022 18:25:50 +0100 Subject: [PATCH 36/48] Added endpoint inheritance through subapis --- decorest/client.py | 14 +++-- decorest/decorator_utils.py | 78 ++++++++++++++++++++++--- decorest/request.py | 22 +++++-- decorest/session.py | 45 +++++++++++--- tests/api_inheritance_tests.py | 103 +++++++++++++++++++++++++++------ tests/decorators_tests.py | 2 +- 6 files changed, 217 insertions(+), 47 deletions(-) diff --git a/decorest/client.py b/decorest/client.py index 029897d..ec66e50 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -22,7 +22,7 @@ import typing import urllib.parse -from .decorator_utils import get_backend_decor, get_endpoint_decor +from .decorator_utils import get_backend_decor from .session import RestClientAsyncSession, RestClientSession from .types import ArgsDict, AuthTypes, Backends from .utils import normalize_url @@ -53,7 +53,7 @@ def __init__(self, self.__backend = backend or get_backend_decor(self) or 'requests' # Check if the client arguments contain endpoint value - self.__endpoint = endpoint or get_endpoint_decor(self) # type: ignore + self.__endpoint = endpoint # type: ignore # Check if the other named arguments match the allowed arguments # for specified backend @@ -147,7 +147,8 @@ def __setitem__(self, key: str, value: typing.Any) -> None: """Set named client argument.""" self.__client_args[key] = value - def build_path_(self, path_components: typing.List[str]) -> str: + def build_path_(self, path_components: typing.List[str], + endpoint: typing.Optional[str]) -> str: """ Build request path. @@ -156,10 +157,13 @@ def build_path_(self, path_components: typing.List[str]) -> str: """ LOG.debug("Building request from path tokens: %s", path_components) - if not self.__endpoint: + if not endpoint: + endpoint = self.__endpoint + + if not endpoint: raise ValueError("Server endpoint was not provided.") - req = urllib.parse.urljoin(normalize_url(self.__endpoint), + req = urllib.parse.urljoin(normalize_url(endpoint), "/".join(path_components)) return req diff --git a/decorest/decorator_utils.py b/decorest/decorator_utils.py index 356909f..4e6a6e9 100644 --- a/decorest/decorator_utils.py +++ b/decorest/decorator_utils.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """Decorator utility functions.""" - +import inspect import numbers import typing @@ -31,16 +31,15 @@ ] -def set_decor(t: typing.Any, name: str, value: typing.Any) -> None: - """Decorate a function or class by storing the value under specific key.""" - if hasattr(t, '__wrapped__') and hasattr(t.__wrapped__, DECOR_KEY): - setattr(t, DECOR_KEY, t.__wrapped__.__decorest__) - - if not hasattr(t, DECOR_KEY): - setattr(t, DECOR_KEY, {}) +def decor_key_cls(cls: type) -> str: + """Get class specific decor key.""" + assert (inspect.isclass(cls)) + return DECOR_KEY + cls.__name__ - d = getattr(t, DECOR_KEY) +def set_decor_value(d: typing.MutableMapping[str, typing.Any], name: str, + value: typing.Any) -> None: + """Set decorator value in the decorator dict.""" if isinstance(value, CaseInsensitiveDict): if not d.get(name): d[name] = CaseInsensitiveDict() @@ -57,6 +56,25 @@ def set_decor(t: typing.Any, name: str, value: typing.Any) -> None: d[name] = value +def set_decor(t: typing.Any, name: str, value: typing.Any) -> None: + """Decorate a function or class by storing the value under specific key.""" + if hasattr(t, '__wrapped__') and hasattr(t.__wrapped__, DECOR_KEY): + setattr(t, DECOR_KEY, t.__wrapped__.__decorest__) + + if not hasattr(t, DECOR_KEY): + setattr(t, DECOR_KEY, {}) + + # Set the decor value in the common decorator + set_decor_value(getattr(t, DECOR_KEY), name, value) + + # Set the decor value in the class specific decorator + # for reverse inheritance of class decors + if inspect.isclass(t): + if not hasattr(t, decor_key_cls(t)): + setattr(t, decor_key_cls(t), {}) + set_decor_value(getattr(t, decor_key_cls(t)), name, value) + + def set_header_decor(t: typing.Any, value: HeaderDict) -> None: """Decorate a function or class with header decorator.""" if hasattr(t, '__wrapped__') and hasattr(t.__wrapped__, DECOR_KEY): @@ -86,12 +104,54 @@ def get_decor(t: typing.Any, name: str) -> typing.Optional[typing.Any]: object: any value assigned to the name key """ + if inspect.isclass(t): + class_decor_key = DECOR_KEY + t.__name__ + if hasattr(t, class_decor_key) and name in getattr(t, class_decor_key): + return getattr(t, class_decor_key)[name] + if hasattr(t, DECOR_KEY) and name in getattr(t, DECOR_KEY): return getattr(t, DECOR_KEY)[name] return None +def get_method_class_decor(f: typing.Any, c: typing.Any, name: str) \ + -> typing.Any: + """Get decorator from base class of c where method f is defined.""" + decor = None + # First find all super classes which 'have' method f + # (this will not work in case of name conflicts) + classes_with_f = [] + for base_class in inspect.getmro(c.__class__): + decor = get_decor(base_class, name) + for m in inspect.getmembers(base_class, predicate=inspect.isfunction): + if m[0] == f.__name__: + classes_with_f.append(base_class) + break + + # Now sort the classes based on the inheritance chain + def sort_by_superclass(a: typing.Any, b: typing.Any) -> int: + """Compare two types according to inheritance relation.""" + if a == b: + return 0 + elif issubclass(b, a): + return -1 + else: + return 1 + + import functools + classes_with_f.sort(key=functools.cmp_to_key(sort_by_superclass)) + + # Now get the decor from the first class in the list which has + # the requested decor + for base in classes_with_f: + decor = get_decor(base, name) + if decor: + break + + return decor + + def get_method_decor(t: typing.Any) -> HttpMethod: """Return http method decor value.""" return typing.cast(HttpMethod, get_decor(t, 'http_method')) diff --git a/decorest/request.py b/decorest/request.py index 85a70a5..0735489 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -24,8 +24,8 @@ from .client import RestClient from .decorator_utils import DECOR_LIST, get_body_decor, get_decor, \ - get_header_decor, get_method_decor, get_on_decor, \ - get_stream_decor, get_timeout_decor + get_header_decor, get_method_class_decor, get_method_decor, \ + get_on_decor, get_stream_decor, get_timeout_decor from .errors import HTTPErrorWrapper from .types import ArgsDict, HTTPErrors, HttpMethod, HttpStatus from .utils import dict_from_args, merge_dicts, render_path @@ -44,6 +44,7 @@ class HttpRequest: kwargs: ArgsDict on_handlers: typing.Mapping[int, typing.Callable[..., typing.Any]] session: typing.Optional[str] + session_endpoint: typing.Optional[str] execution_context: typing.Any rest_client: RestClient @@ -78,6 +79,10 @@ def __init__(self, func: typing.Callable[..., if '__session' in self.kwargs: self.session = self.kwargs['__session'] del self.kwargs['__session'] + self.session_endpoint = None + if '__endpoint' in self.kwargs: + self.session_endpoint = self.kwargs['__endpoint'] + del self.kwargs['__endpoint'] # Merge query parameters from common values for all method # invocations with arguments provided in the method @@ -115,7 +120,7 @@ def __init__(self, func: typing.Callable[..., body_content = body_parameter[1](body_content) # Get authentication method for this call - auth = self.rest_client._auth() + auth = self.rest_client.auth_() # Get status handlers self.on_handlers = merge_dicts( @@ -188,8 +193,15 @@ def __init__(self, func: typing.Callable[..., del self.kwargs['body'] else: pass + # Build request from endpoint and query params - self.req = self.rest_client.build_path_(req_path.split('/')) + effective_endpoint = self.session_endpoint \ + or self.rest_client.endpoint_ \ + or get_method_class_decor(func, self.rest_client, 'endpoint')\ + or get_decor(self.rest_client, 'endpoint') + + self.req = self.rest_client.build_path_(req_path.split('/'), + effective_endpoint) # Handle multipart parameters, either from decorators # or ones passed directly through kwargs @@ -372,4 +384,4 @@ def _merge_args(self, args_dict: ArgsDict, parameters[value] = args_dict[arg] else: parameters[arg] = value - return parameters \ No newline at end of file + return parameters diff --git a/decorest/session.py b/decorest/session.py index 095d773..276cf02 100644 --- a/decorest/session.py +++ b/decorest/session.py @@ -27,9 +27,16 @@ class RestClientSession: """Wrap a `requests` session for specific API client.""" + __client: 'RestClient' + __endpoint: typing.Optional[str] = None + def __init__(self, client: 'RestClient', **kwargs: ArgsDict) -> None: """Initialize the session instance with a specific API client.""" - self.__client: 'RestClient' = client + self.__client = client + + if 'endpoint' in kwargs: + self.__endpoint = typing.cast(str, kwargs['endpoint']) + del kwargs['endpoint'] # Create a session of type specific for given backend if self.__client.backend_ == 'requests': @@ -65,14 +72,13 @@ def __getattr__(self, name: str) -> typing.Any: if name == 'backend_session_': return self.__session - # deprecated - if name == '_requests_session': + if name == '_requests_session': # deprecated return self.__session if name == 'client_' or name == '_client': return self.__client - if name == '_close': + if name == 'close_' or name == '_close': return self.__session.close if name == 'auth_' or name == '_auth': @@ -80,17 +86,30 @@ def __getattr__(self, name: str) -> typing.Any: def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: kwargs['__session'] = self.__session + kwargs['__endpoint'] = self.__endpoint return getattr(self.__client, name)(*args, **kwargs) return invoker + @property + def endpoint_(self) -> typing.Optional[str]: + """Return session specific endpoint.""" + return self.__endpoint + class RestClientAsyncSession: """Wrap a `requests` session for specific API client.""" + __client: 'RestClient' + __endpoint: typing.Optional[str] = None + def __init__(self, client: 'RestClient', **kwargs: ArgsDict) \ -> None: """Initialize the session instance with a specific API client.""" - self.__client: 'RestClient' = client + self.__client = client + + if 'endpoint' in kwargs: + self.__endpoint = typing.cast(str, kwargs['endpoint']) + del kwargs['endpoint'] # Create a session of type specific for given backend import httpx @@ -108,16 +127,19 @@ async def __aexit__(self, *args: typing.Any) -> None: def __getattr__(self, name: str) -> typing.Any: """Forward any method invocation to actual client with session.""" - if name == '_requests_session': + if name == 'backend_session_': return self.__session - if name == '_client': + if name == '_requests_session': # deprecated + return self.__session + + if name == 'client_' or name == '_client': return self.__client - if name == '_close': + if name == 'close_' or name == '_close': return self.__session.aclose - if name == '_auth': + if name == 'auth_' or name == '_auth': return self.__session.auth async def invoker(*args: typing.Any, @@ -129,3 +151,8 @@ async def invoker(*args: typing.Any, return await getattr(self.__client, name)(*args, **kwargs) return invoker + + @property + def endpoint_(self) -> typing.Optional[str]: + """Return session specific endpoint.""" + return self.__endpoint diff --git a/tests/api_inheritance_tests.py b/tests/api_inheritance_tests.py index 016e284..170bb91 100644 --- a/tests/api_inheritance_tests.py +++ b/tests/api_inheritance_tests.py @@ -29,23 +29,35 @@ from decorest import GET, PATCH, PUT -class APIOne(RestClient): +class A(RestClient): """API One client""" - @GET('stuff/{what}') + @GET('stuff/{sth}') @on(200, lambda r: r.json()) - def get(self, what: str) -> typing.Any: + def get(self, sth: str) -> typing.Any: """Get what""" -class APITwo(RestClient): +class B(RestClient): """API One client""" - @PUT('stuff/{what}') + @PUT('stuff/{sth}') @body('body') - def put(self, sth: str, body: bytes) -> typing.Any: + @on(204, lambda _: True) + def put_b(self, sth: str, body: bytes) -> typing.Any: + """Put sth""" + + +@endpoint('https://put.example.com') +class BB(B): + """API One client""" + @PUT('stuff/{sth}') + @body('body') + @on(204, lambda _: True) + def put_bb(self, sth: str, body: bytes) -> typing.Any: """Put sth""" -class APIThree(RestClient): +@endpoint('https://patches.example.com') +class C(RestClient): """API Three client""" @PATCH('stuff/{sth}') @body('body') @@ -60,7 +72,7 @@ def patch(self, sth: str, body: bytes) -> typing.Any: @header('X-Auth-Key', 'ABCD') @endpoint('https://example.com') @backend('httpx') -class InheritedClient(APITwo, APIOne, APIThree): +class InheritedClient(A, BB, C): ... @@ -72,20 +84,20 @@ def test_api_inheritance_properties() -> None: client = InheritedClient() assert client.backend_ == 'httpx' - assert client.endpoint_ == 'https://example.com' + assert client.endpoint_ is None # 'https://example.com' - client = InheritedClient(backend='requests', - endpoint="https://patches.example.com") + client = InheritedClient('https://patches.example.com', backend='requests') assert client.backend_ == 'requests' assert client.endpoint_ == 'https://patches.example.com' -def test_api_inheritance_basic(respx_mock) -> None: +@pytest.mark.parametrize("backend", ['httpx']) +def test_api_inheritance_basic(respx_mock, backend) -> None: """ """ - client = InheritedClient() + client = InheritedClient(backend=backend) expected = dict(id=1, name='thing1') req = respx_mock.get("https://example.com/stuff/thing1")\ @@ -96,12 +108,22 @@ def test_api_inheritance_basic(respx_mock) -> None: assert req.called is True assert res == expected + client = InheritedClient('https://example2.com', backend=backend) + + req = respx_mock.get('https://example2.com/stuff/thing1')\ + .mock(return_value=httpx.Response(200, content=json.dumps(expected))) + + res = client.get('thing1') + + assert req.called is True + assert res == expected -def test_api_inheritance_custom_endpoint(respx_mock) -> None: - """ +@pytest.mark.parametrize("backend", ['httpx']) +def test_api_inheritance_custom_endpoint(respx_mock, backend) -> None: """ - client = InheritedClient(endpoint='https://patches.example.com') + """ + client = InheritedClient(backend=backend) req = respx_mock.patch("https://patches.example.com/stuff/thing1")\ .mock(return_value=httpx.Response(204)) @@ -112,11 +134,56 @@ def test_api_inheritance_custom_endpoint(respx_mock) -> None: assert req.called is True assert res is True - req = respx_mock.patch("https://patches.example.com/stuff/thing1")\ + req = respx_mock.patch("https://patches.example.com/stuff/thing2")\ .mock(return_value=httpx.Response(500)) - res = client.patch('thing1', + res = client.patch('thing2', body=json.loads('{"id": 1, "notname": "thing2"}')) assert req.called is True assert not res + + with client.session_() as s: + res = s.patch('thing1', body=json.loads('{"id": 3, "name": "thing3"}')) + assert req.called is True + assert res is True + + req = respx_mock.patch("https://patches2.example.com/stuff/thing1")\ + .mock(return_value=httpx.Response(204)) + with client.session_(endpoint="https://patches2.example.com") as s: + res = s.patch('thing1', body=json.loads('{"id": 3, "name": "thing3"}')) + assert req.called is True + assert res is True + + redirect_headers = { + 'Location': 'https://patches3.example.com/stuff/thing1' + } + req = respx_mock.patch("https://patches.example.com/stuff/thing1").mock( + return_value=httpx.Response(301, headers=redirect_headers)) + + req_redirect \ + = respx_mock.patch("https://patches3.example.com/stuff/thing1").mock( + return_value=httpx.Response(204)) + + with client.session_() as s: + res = s.patch('thing1', body=json.loads('{"id": 3, "name": "thing3"}')) + assert req.called and req_redirect.called + assert res is True + + req = respx_mock.put("https://put.example.com/stuff/thing1")\ + .mock(return_value=httpx.Response(204)) + + res = client.put_b('thing1', + body=json.loads('{"id": 1, "name": "thing2"}')) + + assert req.called is True + assert res + + req = respx_mock.put("https://put.example.com/stuff/thing2")\ + .mock(return_value=httpx.Response(204)) + + res = client.put_bb('thing2', + body=json.loads('{"id": 1, "name": "thing2"}')) + + assert req.called is True + assert res diff --git a/tests/decorators_tests.py b/tests/decorators_tests.py index d197c2e..3c3befb 100644 --- a/tests/decorators_tests.py +++ b/tests/decorators_tests.py @@ -159,7 +159,7 @@ def test_endpoint_decorator() -> None: default_client = DogClient() - assert default_client.endpoint_ == 'https://dog.ceo/' + assert default_client.endpoint_ is None # 'https://dog.ceo/' custom_client = DogClient('http://dogceo.example.com') From 69053cd2664afa75f6a2b84c28f8f14d1154f067 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sat, 8 Jan 2022 20:32:26 +0100 Subject: [PATCH 37/48] Updated copyright period --- decorest/DELETE.py | 2 +- decorest/GET.py | 2 +- decorest/HEAD.py | 2 +- decorest/OPTIONS.py | 2 +- decorest/PATCH.py | 2 +- decorest/POST.py | 2 +- decorest/PUT.py | 2 +- decorest/__init__.py | 2 +- decorest/client.py | 2 +- decorest/decorator_utils.py | 2 +- decorest/decorators.py | 2 +- decorest/errors.py | 2 +- decorest/request.py | 2 +- decorest/session.py | 2 +- decorest/types.py | 2 +- decorest/utils.py | 2 +- examples/__init__.py | 2 +- examples/httpbin/__init__.py | 2 +- examples/httpbin/httpbin_async_client.py | 2 +- examples/httpbin/httpbin_client.py | 2 +- examples/httpbin/httpbin_client_with_typing.py | 2 +- examples/swagger_petstore/__init__.py | 2 +- examples/swagger_petstore/petstore_client.py | 2 +- examples/swagger_petstore/petstore_client_with_typing.py | 2 +- setup.py | 2 +- tests/api_inheritance_tests.py | 2 +- tests/decorators_tests.py | 2 +- tests/httpbin_async_test.py | 2 +- tests/httpbin_test.py | 2 +- tests/petstore_test.py | 2 +- tests/petstore_test_with_typing.py | 2 +- 31 files changed, 31 insertions(+), 31 deletions(-) diff --git a/decorest/DELETE.py b/decorest/DELETE.py index b48e317..4dabe44 100644 --- a/decorest/DELETE.py +++ b/decorest/DELETE.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/GET.py b/decorest/GET.py index fce7398..87790ec 100644 --- a/decorest/GET.py +++ b/decorest/GET.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/HEAD.py b/decorest/HEAD.py index b2b8ecf..aa181bf 100644 --- a/decorest/HEAD.py +++ b/decorest/HEAD.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/OPTIONS.py b/decorest/OPTIONS.py index 4c93996..5d2ca7e 100644 --- a/decorest/OPTIONS.py +++ b/decorest/OPTIONS.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/PATCH.py b/decorest/PATCH.py index 6e940c6..bffb96b 100644 --- a/decorest/PATCH.py +++ b/decorest/PATCH.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/POST.py b/decorest/POST.py index dfad0bf..0fff385 100644 --- a/decorest/POST.py +++ b/decorest/POST.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/PUT.py b/decorest/PUT.py index 3c96377..b2862fc 100644 --- a/decorest/PUT.py +++ b/decorest/PUT.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/__init__.py b/decorest/__init__.py index 6bb9272..28878b0 100644 --- a/decorest/__init__.py +++ b/decorest/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/client.py b/decorest/client.py index ec66e50..c559ee0 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/decorator_utils.py b/decorest/decorator_utils.py index 4e6a6e9..3714171 100644 --- a/decorest/decorator_utils.py +++ b/decorest/decorator_utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/decorators.py b/decorest/decorators.py index 2268d3f..bb1d980 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/errors.py b/decorest/errors.py index fc73c6a..8d332e7 100644 --- a/decorest/errors.py +++ b/decorest/errors.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/request.py b/decorest/request.py index 0735489..3c2d179 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/session.py b/decorest/session.py index 276cf02..612581a 100644 --- a/decorest/session.py +++ b/decorest/session.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/types.py b/decorest/types.py index 438187a..752b435 100644 --- a/decorest/types.py +++ b/decorest/types.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/decorest/utils.py b/decorest/utils.py index cdaa636..68a32ae 100644 --- a/decorest/utils.py +++ b/decorest/utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/examples/__init__.py b/examples/__init__.py index 8e6c3d4..94e42e0 100644 --- a/examples/__init__.py +++ b/examples/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/examples/httpbin/__init__.py b/examples/httpbin/__init__.py index 66fcf2b..6c33115 100644 --- a/examples/httpbin/__init__.py +++ b/examples/httpbin/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/examples/httpbin/httpbin_async_client.py b/examples/httpbin/httpbin_async_client.py index a752a13..2039b7d 100644 --- a/examples/httpbin/httpbin_async_client.py +++ b/examples/httpbin/httpbin_async_client.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/examples/httpbin/httpbin_client.py b/examples/httpbin/httpbin_client.py index 44c57c9..8b92713 100644 --- a/examples/httpbin/httpbin_client.py +++ b/examples/httpbin/httpbin_client.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/examples/httpbin/httpbin_client_with_typing.py b/examples/httpbin/httpbin_client_with_typing.py index a187ca2..c670c1d 100644 --- a/examples/httpbin/httpbin_client_with_typing.py +++ b/examples/httpbin/httpbin_client_with_typing.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/examples/swagger_petstore/__init__.py b/examples/swagger_petstore/__init__.py index 3615ee1..24c5cd5 100644 --- a/examples/swagger_petstore/__init__.py +++ b/examples/swagger_petstore/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/examples/swagger_petstore/petstore_client.py b/examples/swagger_petstore/petstore_client.py index 5f58628..b140021 100644 --- a/examples/swagger_petstore/petstore_client.py +++ b/examples/swagger_petstore/petstore_client.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/examples/swagger_petstore/petstore_client_with_typing.py b/examples/swagger_petstore/petstore_client_with_typing.py index 42253fe..743f1cc 100644 --- a/examples/swagger_petstore/petstore_client_with_typing.py +++ b/examples/swagger_petstore/petstore_client_with_typing.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/setup.py b/setup.py index 37038a7..b9581f0 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/api_inheritance_tests.py b/tests/api_inheritance_tests.py index 170bb91..f5de5f1 100644 --- a/tests/api_inheritance_tests.py +++ b/tests/api_inheritance_tests.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/decorators_tests.py b/tests/decorators_tests.py index 3c3befb..20fff4f 100644 --- a/tests/decorators_tests.py +++ b/tests/decorators_tests.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index b36a37e..dbffafc 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/httpbin_test.py b/tests/httpbin_test.py index b810c97..54ba67d 100644 --- a/tests/httpbin_test.py +++ b/tests/httpbin_test.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/petstore_test.py b/tests/petstore_test.py index fb81472..7b76fea 100644 --- a/tests/petstore_test.py +++ b/tests/petstore_test.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. diff --git a/tests/petstore_test_with_typing.py b/tests/petstore_test_with_typing.py index 311c05a..ca8b63b 100644 --- a/tests/petstore_test_with_typing.py +++ b/tests/petstore_test_with_typing.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright 2018-2021 Bartosz Kryza +# Copyright 2018-2022 Bartosz Kryza # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. From 4009e4011405e121e95efee2503d8a88da51c7ab Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 9 Jan 2022 14:03:59 +0100 Subject: [PATCH 38/48] Increased test coverage --- decorest/client.py | 4 +- decorest/decorator_utils.py | 52 ++++- decorest/decorators.py | 3 +- decorest/types.py | 3 +- decorest/utils.py | 4 +- examples/httpbin/httpbin_async_client.py | 12 +- examples/httpbin/httpbin_client.py | 12 +- examples/swagger_petstore/petstore_client.py | 6 +- tests/decorators_tests.py | 224 ++++++++++++++++++- tests/httpbin_async_test.py | 18 ++ tests/httpbin_test.py | 20 +- 11 files changed, 335 insertions(+), 23 deletions(-) diff --git a/decorest/client.py b/decorest/client.py index c559ee0..cb1a56c 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -66,11 +66,11 @@ def __init__(self, valid_client_args \ = inspect.getfullargspec(httpx.Client.__init__).kwonlyargs else: - raise ValueError(f'Invalid backend: {self._backend}') + raise ValueError(f'Invalid backend: {self.backend_}') if not set(kwargs.keys()).issubset(set(valid_client_args)): raise ValueError(f'Invalid named arguments passed to the client: ' - f'{set(valid_client_args) - set(kwargs.keys())}') + f'{set(kwargs.keys()) - set(valid_client_args)}') self.__client_args = kwargs diff --git a/decorest/decorator_utils.py b/decorest/decorator_utils.py index 3714171..3b22495 100644 --- a/decorest/decorator_utils.py +++ b/decorest/decorator_utils.py @@ -40,11 +40,7 @@ def decor_key_cls(cls: type) -> str: def set_decor_value(d: typing.MutableMapping[str, typing.Any], name: str, value: typing.Any) -> None: """Set decorator value in the decorator dict.""" - if isinstance(value, CaseInsensitiveDict): - if not d.get(name): - d[name] = CaseInsensitiveDict() - d[name] = merge_dicts(d[name], value) - elif isinstance(value, dict): + if isinstance(value, dict): if not d.get(name): d[name] = {} d[name] = merge_dicts(d[name], value) @@ -92,12 +88,13 @@ def set_header_decor(t: typing.Any, value: HeaderDict) -> None: d[name] = merge_header_dicts(d[name], value) -def get_decor(t: typing.Any, name: str) -> typing.Optional[typing.Any]: +def get_class_specific_decor(t: typing.Any, + name: str) -> typing.Optional[typing.Any]: """ - Retrieve a named decorator value from class or function. + Retrieve a named decorator value from specific class. Args: - t (type): Decorated type (can be class or function) + t (type): Decorated class name (str): Name of the key Returns: @@ -109,6 +106,25 @@ def get_decor(t: typing.Any, name: str) -> typing.Optional[typing.Any]: if hasattr(t, class_decor_key) and name in getattr(t, class_decor_key): return getattr(t, class_decor_key)[name] + return None + + +def get_decor(t: typing.Any, name: str) -> typing.Optional[typing.Any]: + """ + Retrieve a named decorator value from class or function. + + Args: + t (type): Decorated type (can be class or function) + name (str): Name of the key + + Returns: + object: any value assigned to the name key + + """ + class_specific_decor = get_class_specific_decor(t, name) + if class_specific_decor: + return class_specific_decor + if hasattr(t, DECOR_KEY) and name in getattr(t, DECOR_KEY): return getattr(t, DECOR_KEY)[name] @@ -145,7 +161,7 @@ def sort_by_superclass(a: typing.Any, b: typing.Any) -> int: # Now get the decor from the first class in the list which has # the requested decor for base in classes_with_f: - decor = get_decor(base, name) + decor = get_class_specific_decor(base, name) if decor: break @@ -191,12 +207,26 @@ def get_on_decor(t: typing.Any) \ def get_accept_decor(t: typing.Any) -> typing.Optional[str]: """Return accept decor value.""" - return typing.cast(typing.Optional[str], get_decor(t, 'accept')) + header_decor = get_header_decor(t) + if not header_decor: + return None + + if 'accept' in header_decor: + return header_decor['accept'] + + return None def get_content_decor(t: typing.Any) -> typing.Optional[str]: """Return content-type decor value.""" - return typing.cast(typing.Optional[str], get_decor(t, 'content')) + header_decor = get_header_decor(t) + if not header_decor: + return None + + if 'content-type' in header_decor: + return header_decor['content-type'] + + return None def get_timeout_decor(t: typing.Any) -> typing.Optional[numbers.Real]: diff --git a/decorest/decorators.py b/decorest/decorators.py index bb1d980..331931e 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -49,7 +49,8 @@ def on_decorator(t: TDecor) -> TDecor: elif isinstance(status, numbers.Integral): set_decor(t, 'on', {status: handler}) else: - raise TypeError("Status in @on decorator must be integer or '...'") + raise TypeError( + "Status in @on decorator must be integer or '...'.") return t return on_decorator diff --git a/decorest/types.py b/decorest/types.py index 752b435..94b302c 100644 --- a/decorest/types.py +++ b/decorest/types.py @@ -33,7 +33,8 @@ class HttpMethod(DEnum): PATCH = 'PATCH', DELETE = 'DELETE', HEAD = 'HEAD', - OPTIONS = 'OPTIONS' + OPTIONS = 'OPTIONS', + INVALID = '' # without this 'OPTIONS' becomes 'O' class HttpStatus(DIntEnum): diff --git a/decorest/utils.py b/decorest/utils.py index 68a32ae..046f4cc 100644 --- a/decorest/utils.py +++ b/decorest/utils.py @@ -31,8 +31,8 @@ def render_path(path: str, args: ArgsDict) -> str: while matches: path_token = matches.group(1) if path_token not in args: - raise ValueError("Missing argument %s in REST call" % (path_token)) - result = re.sub('{%s}' % (path_token), str(args[path_token]), result) + raise ValueError("Missing argument %s in REST call." % path_token) + result = re.sub('{%s}' % path_token, str(args[path_token]), result) matches = re.search(r'{([^}.]*)}', result) return result diff --git a/examples/httpbin/httpbin_async_client.py b/examples/httpbin/httpbin_async_client.py index 2039b7d..ad9ace4 100644 --- a/examples/httpbin/httpbin_async_client.py +++ b/examples/httpbin/httpbin_async_client.py @@ -21,7 +21,7 @@ from PIL import Image -from decorest import DELETE, GET, PATCH, POST, PUT +from decorest import DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT from decorest import HttpStatus, RestClient from decorest import __version__, accept, body, content, endpoint, form from decorest import backend, header, multipart, on, query, stream, timeout @@ -58,6 +58,16 @@ class HttpBinAsyncClient(RestClient): async def ip(self): """Return Origin IP.""" + @HEAD('ip') + @on(200, lambda _: True) + async def head_ip(self): + """Return Origin IP request headers only.""" + + @OPTIONS('ip') + @on(200, lambda r: r.headers) + async def options_ip(self): + """Return Origin IP options.""" + @repeatdecorator @GET('ip') async def ip_repeat(self): diff --git a/examples/httpbin/httpbin_client.py b/examples/httpbin/httpbin_client.py index 8b92713..0d5f783 100644 --- a/examples/httpbin/httpbin_client.py +++ b/examples/httpbin/httpbin_client.py @@ -21,7 +21,7 @@ from PIL import Image -from decorest import DELETE, GET, PATCH, POST, PUT +from decorest import DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT from decorest import HttpStatus, RestClient from decorest import __version__, accept, body, content, endpoint, form from decorest import header, multipart, on, query, stream, timeout @@ -57,6 +57,16 @@ class HttpBinClient(RestClient): def ip(self): """Return Origin IP.""" + @HEAD('ip') + @on(200, lambda _: True) + def head_ip(self): + """Return Origin IP request headers only.""" + + @OPTIONS('ip') + @on(200, lambda r: r.headers) + def options_ip(self): + """Return Origin IP options.""" + @repeatdecorator @GET('ip') def ip_repeat(self): diff --git a/examples/swagger_petstore/petstore_client.py b/examples/swagger_petstore/petstore_client.py index b140021..cd3b191 100644 --- a/examples/swagger_petstore/petstore_client.py +++ b/examples/swagger_petstore/petstore_client.py @@ -23,7 +23,7 @@ import json import xml.etree.ElementTree as ET -from decorest import DELETE, GET, POST, PUT +from decorest import DELETE, GET, HEAD, POST, PUT from decorest import HttpStatus, RestClient from decorest import __version__ from decorest import accept, body, content, endpoint, header, on, query @@ -59,6 +59,10 @@ def find_pet_by_status_xml(self): def find_pet_by_id(self, pet_id): """Find Pet by ID.""" + @HEAD('pet/{pet_id}') + def head_find_pet(self, pet_id): + """Head find Pet by ID.""" + @POST('pet/{pet_id}') def update_pet_by_id(self, pet_id): """Update a pet in the store with form data.""" diff --git a/tests/decorators_tests.py b/tests/decorators_tests.py index 20fff4f..7ba3f71 100644 --- a/tests/decorators_tests.py +++ b/tests/decorators_tests.py @@ -21,13 +21,14 @@ from requests.auth import HTTPBasicAuth as r_HTTPBasicAuth from httpx import BasicAuth as x_HTTPBasicAuth -from decorest import RestClient, HttpMethod +from decorest import RestClient, HttpMethod, HTTPErrorWrapper, multipart, on from decorest import GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS from decorest import accept, backend, content, endpoint, form, header from decorest import query, stream from decorest.decorator_utils import get_backend_decor, get_header_decor, \ get_endpoint_decor, get_form_decor, get_query_decor, \ - get_stream_decor, get_on_decor, get_method_decor + get_stream_decor, get_on_decor, get_method_decor, get_method_class_decor, get_multipart_decor, get_accept_decor, \ + get_content_decor @accept('application/json') @@ -78,6 +79,13 @@ def post(self, a: str) -> None: def post_form(self, key1: str, key2: str) -> None: """Post 2 keys""" + @POST('post') + @multipart('part1') + @multipart('part_2', 'part2') + @multipart('test') + async def post_multipart(self, part1, part_2, test): + """Return multipart POST data.""" + @PUT('put') def put(self, a: str) -> None: """Put something""" @@ -128,6 +136,11 @@ class and methods. assert get_endpoint_decor(DogClient) == 'https://dog.ceo/' + assert get_accept_decor(DogClient) == 'application/json' + assert get_content_decor(DogClient) == 'application/xml' + assert get_content_decor(DogClient.headers) == 'application/json' + assert get_accept_decor(DogClient.headers) == 'application/xml' + assert get_method_decor(DogClient.get) == HttpMethod.GET assert get_method_decor(DogClient.post) == HttpMethod.POST assert get_method_decor(DogClient.put) == HttpMethod.PUT @@ -141,6 +154,13 @@ class and methods. 'key1': 'key1', 'key2': 'keyTwo' } + + assert get_multipart_decor(DogClient.post_multipart) == { + 'part1': 'part1', + 'part_2': 'part2', + 'test': 'test' + } + assert get_query_decor(DogClient.queries) == {'a': 'a', 'b': 'b', 'c': 'd'} assert get_stream_decor(DogClient.stream_range) is True @@ -166,6 +186,143 @@ def test_endpoint_decorator() -> None: assert custom_client.endpoint_ == 'http://dogceo.example.com' +def test_missing_endpoint_decorator() -> None: + """ + """ + class EmptyClient(RestClient): + """EmptyClient client""" + @GET('{sth}') + def get(self, sth: str) -> typing.Any: + """Get sth""" + + with pytest.raises(ValueError) as e: + default_client = EmptyClient() + default_client.get('stuff') + + assert str(e.value) == 'Server endpoint was not provided.' + + +def test_invalid_on_decorator() -> None: + """ + """ + with pytest.raises(TypeError) as e: + + class EmptyClient(RestClient): + """EmptyClient client""" + @GET('{sth}') + @on('200', lambda x: x) + def get(self, sth: str) -> typing.Any: + """Get sth""" + + client = EmptyClient() + + assert str(e.value) == "Status in @on decorator must be integer or '...'." + + +def test_invalid_query_decorator() -> None: + """ + """ + with pytest.raises(TypeError) as e: + + @query('{sthelse}') + class EmptyClient(RestClient): + """EmptyClient client""" + @GET('{sth}') + def get(self, sth: str) -> typing.Any: + """Get sth""" + + client = EmptyClient() + + assert str(e.value) == "@query decorator can only be applied to methods." + + +def test_invalid_multipart_decorator() -> None: + """ + """ + with pytest.raises(TypeError) as e: + + @multipart('{sthelse}') + class EmptyClient(RestClient): + """EmptyClient client""" + @GET('{sth}') + def get(self, sth: str) -> typing.Any: + """Get sth""" + + client = EmptyClient() + + assert str( + e.value) == "@multipart decorator can only be applied to methods." + + +def test_invalid_form_decorator() -> None: + """ + """ + with pytest.raises(TypeError) as e: + + @form('{sthelse}') + class EmptyClient(RestClient): + """EmptyClient client""" + @GET('{sth}') + def get(self, sth: str) -> typing.Any: + """Get sth""" + + client = EmptyClient() + + assert str(e.value) == "@form decorator can only be applied to methods." + + +def test_invalid_backend_decorator() -> None: + """ + """ + with pytest.raises(TypeError) as e: + + class EmptyClient(RestClient): + """EmptyClient client""" + @GET('{sth}') + @backend('http://example.com') + def get(self, sth: str) -> typing.Any: + """Get sth""" + + client = EmptyClient() + + assert str(e.value) == "@backend decorator can only be applied to classes." + + +def test_missing_path_argument() -> None: + """ + """ + class EmptyClient(RestClient): + """EmptyClient client""" + @GET('{something}') + def get(self, sth: str) -> typing.Any: + """Get sth""" + + with pytest.raises(ValueError) as e: + default_client = EmptyClient() + default_client.get('stuff') + + assert str(e.value) == 'Missing argument something in REST call.' + + +def test_invalid_backend() -> None: + """ + """ + with pytest.raises(ValueError) as e: + client = DogClient(backend='yahl') + + assert str(e.value) == 'Invalid backend: yahl' + + +def test_invalid_client_named_args() -> None: + """ + """ + with pytest.raises(ValueError) as e: + client = DogClient(no_such_arg='foo') + + assert str(e.value) == "Invalid named arguments passed " \ + "to the client: {'no_such_arg'}" + + def test_introspection() -> None: """ Make sure the decorators maintain the original method @@ -218,6 +375,69 @@ def test_introspection() -> None: assert d == DogClient.plain_stream_range.__dict__ +def test_get_method_class_decor() -> None: + """ + + """ + @endpoint('http://a.example.com') + class A(RestClient): + @GET('{a}') + def a(self, a: str): + ... + + class B(RestClient): + @GET('{b}') + def b(self, b: str): + ... + + @endpoint('http://bb.example.com') + class BB(B): + @GET('{bb}') + def bb(self, bb: str): + ... + + class BBB(BB): + @GET('{bbb}') + def bbb(self, bbb: str): + ... + + class BBB2(BB): + @GET('{bbb2}') + def bbb2(self, bbb2: str): + ... + + class C(RestClient): + @GET('{c}') + def c(self, c: str): + ... + + class CC(C): + @GET('{cc}') + def cc(self, cc: str): + ... + + @endpoint('http://example.com') + class Client(A, BBB, BBB2, CC): + ... + + client = Client() + + assert get_method_class_decor(A.a, client, + 'endpoint') == 'http://a.example.com' + assert get_method_class_decor(B.b, client, + 'endpoint') == 'http://bb.example.com' + assert get_method_class_decor(BB.bb, client, + 'endpoint') == 'http://bb.example.com' + assert get_method_class_decor(BBB.bbb, client, + 'endpoint') == 'http://example.com' + assert get_method_class_decor(BBB2.bbb2, client, + 'endpoint') == 'http://example.com' + assert get_method_class_decor(C.c, client, + 'endpoint') == 'http://example.com' + assert get_method_class_decor(CC.cc, client, + 'endpoint') == 'http://example.com' + + def test_authentication_settings() -> None: """ Tests if authentication is properly configured. diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index dbffafc..34b97a2 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -70,6 +70,24 @@ async def test_ip(client): assert "origin" in res +@pytest.mark.asyncio +async def test_ip_head(client): + """ + """ + res = await client.head_ip() + + assert res + + +@pytest.mark.asyncio +async def test_ip_options(client): + """ + """ + res = await client.options_ip() + + assert sorted(res['allow'].split(", ")) == ['GET', 'HEAD', 'OPTIONS'] + + @pytest.mark.asyncio async def test_ip_repeat(client): """ diff --git a/tests/httpbin_test.py b/tests/httpbin_test.py index 54ba67d..4bb5191 100644 --- a/tests/httpbin_test.py +++ b/tests/httpbin_test.py @@ -89,6 +89,24 @@ def test_ip(client): assert "origin" in res +@pytest.mark.parametrize("client", pytest_params) +def test_ip_head(client): + """ + """ + res = client.head_ip() + + assert res + + +@pytest.mark.parametrize("client", pytest_params) +def test_ip_options(client): + """ + """ + res = client.options_ip() + + assert sorted(res['allow'].split(", ")) == ['GET', 'HEAD', 'OPTIONS'] + + @pytest.mark.parametrize("client", pytest_params) def test_ip_repeat(client): """ @@ -368,7 +386,7 @@ def test_redirect_to(client): """ res = client.redirect_to('http://httpbin.org', on={302: lambda r: 'REDIRECTED'}, - allow_redirects=False) + follow_redirects=False) assert res == 'REDIRECTED' From e8a262aabe0cd04d73a37a6f0cad4930b6e89046 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 9 Jan 2022 19:28:59 +0100 Subject: [PATCH 39/48] Added __repr__ method to main classes --- decorest/client.py | 23 ++++++++++++++--------- decorest/decorators.py | 5 +---- decorest/request.py | 9 +++++++-- decorest/session.py | 8 ++++++++ decorest/types.py | 4 ++++ tests/decorators_tests.py | 28 ++++++++++++++++++++++++++-- 6 files changed, 60 insertions(+), 17 deletions(-) diff --git a/decorest/client.py b/decorest/client.py index cb1a56c..9bff01b 100644 --- a/decorest/client.py +++ b/decorest/client.py @@ -22,7 +22,7 @@ import typing import urllib.parse -from .decorator_utils import get_backend_decor +from .decorator_utils import get_backend_decor, get_endpoint_decor from .session import RestClientAsyncSession, RestClientSession from .types import ArgsDict, AuthTypes, Backends from .utils import normalize_url @@ -74,6 +74,19 @@ def __init__(self, self.__client_args = kwargs + def __getitem__(self, key: str) -> typing.Any: + """Return named client argument.""" + return self._get_or_none(key) + + def __setitem__(self, key: str, value: typing.Any) -> None: + """Set named client argument.""" + self.__client_args[key] = value + + def __repr__(self) -> str: + """Return instance representation.""" + return f'<{type(self).__name__} backend: {self.__backend} ' \ + f'endpoint: \'{self.__endpoint or get_endpoint_decor(self)}\'>' + def session_(self, **kwargs: ArgsDict) -> RestClientSession: """ Initialize RestClientSession session object. @@ -139,14 +152,6 @@ def _get_or_none(self, key: str) -> typing.Any: return None - def __getitem__(self, key: str) -> typing.Any: - """Return named client argument.""" - return self._get_or_none(key) - - def __setitem__(self, key: str, value: typing.Any) -> None: - """Set named client argument.""" - self.__client_args[key] = value - def build_path_(self, path_components: typing.List[str], endpoint: typing.Optional[str]) -> str: """ diff --git a/decorest/decorators.py b/decorest/decorators.py index 331931e..d210b73 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -273,10 +273,7 @@ def _dispatch(self, http_request: HttpRequest) -> typing.Any: kwargs(dict): named arguments passed to the API method req(): request object """ - if isinstance(http_request.http_method, str): - method = http_request.http_method - else: - method = http_request.http_method.value[0].lower() + method = str(http_request.http_method).lower() ctx = http_request.execution_context diff --git a/decorest/request.py b/decorest/request.py index 3c2d179..921091a 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -74,7 +74,7 @@ def __init__(self, func: typing.Callable[..., self.rest_client = args[0] args_dict = dict_from_args(func, *args) - req_path = render_path(self.path_template, args_dict) + self.req_path = render_path(self.path_template, args_dict) self.session = None if '__session' in self.kwargs: self.session = self.kwargs['__session'] @@ -200,7 +200,7 @@ def __init__(self, func: typing.Callable[..., or get_method_class_decor(func, self.rest_client, 'endpoint')\ or get_decor(self.rest_client, 'endpoint') - self.req = self.rest_client.build_path_(req_path.split('/'), + self.req = self.rest_client.build_path_(self.req_path.split('/'), effective_endpoint) # Handle multipart parameters, either from decorators @@ -292,6 +292,11 @@ def __init__(self, func: typing.Callable[..., else: self._normalize_for_httpx(self.kwargs) + def __repr__(self) -> str: + """Return instance representation.""" + return f'<{type(self).__name__} method: {str(self.http_method)} ' \ + f'path: \'{self.req_path}\'>' + def _normalize_for_httpx(self, kwargs: ArgsDict) -> None: """ Normalize kwargs for httpx. diff --git a/decorest/session.py b/decorest/session.py index 612581a..723e047 100644 --- a/decorest/session.py +++ b/decorest/session.py @@ -91,6 +91,10 @@ def invoker(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: return invoker + def __repr__(self) -> str: + """Return instance representation.""" + return f'<{type(self).__name__} client: {repr(self.__client)}>' + @property def endpoint_(self) -> typing.Optional[str]: """Return session specific endpoint.""" @@ -152,6 +156,10 @@ async def invoker(*args: typing.Any, return invoker + def __repr__(self) -> str: + """Return instance representation.""" + return f'<{type(self).__name__} client: {repr(self.__client)}>' + @property def endpoint_(self) -> typing.Optional[str]: """Return session specific endpoint.""" diff --git a/decorest/types.py b/decorest/types.py index 94b302c..a737985 100644 --- a/decorest/types.py +++ b/decorest/types.py @@ -36,6 +36,10 @@ class HttpMethod(DEnum): OPTIONS = 'OPTIONS', INVALID = '' # without this 'OPTIONS' becomes 'O' + def __str__(self) -> str: + """Return string representation.""" + return typing.cast(str, self.value[0]) + class HttpStatus(DIntEnum): """Enum with HTTP error code classes.""" diff --git a/tests/decorators_tests.py b/tests/decorators_tests.py index 7ba3f71..037195d 100644 --- a/tests/decorators_tests.py +++ b/tests/decorators_tests.py @@ -21,14 +21,14 @@ from requests.auth import HTTPBasicAuth as r_HTTPBasicAuth from httpx import BasicAuth as x_HTTPBasicAuth -from decorest import RestClient, HttpMethod, HTTPErrorWrapper, multipart, on +from decorest import RestClient, HttpMethod, HTTPErrorWrapper, multipart, on, HttpRequest from decorest import GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS from decorest import accept, backend, content, endpoint, form, header from decorest import query, stream from decorest.decorator_utils import get_backend_decor, get_header_decor, \ get_endpoint_decor, get_form_decor, get_query_decor, \ get_stream_decor, get_on_decor, get_method_decor, get_method_class_decor, get_multipart_decor, get_accept_decor, \ - get_content_decor + get_content_decor, set_decor @accept('application/json') @@ -505,3 +505,27 @@ def test_authentication_settings_deprecated() -> None: x_session_auth = x_client_auth._session() assert x_session_auth._auth._auth_header == \ x_HTTPBasicAuth('username', 'password')._auth_header + + +def test_repr_methods(): + r_client = DogClient(backend='requests') + assert repr(r_client) \ + == '' + + x_client = DogClient(backend='httpx') + assert repr(x_client) \ + == '' + + class MockClient(RestClient): + def func(self, breed_name: str): + return breed_name + + set_decor(MockClient.func, 'http_method', HttpMethod.GET) + req = HttpRequest(MockClient.func, + 'breed/{breed_name}/list', + args=(r_client, "dog"), + kwargs={}) + assert repr(req) == '' + + with r_client.session_() as s: + assert repr(s) == f'' From 42d43124dc8e5c1beae34a3df55075c392b55b33 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Sun, 9 Jan 2022 23:47:54 +0100 Subject: [PATCH 40/48] Replaced requests.CaseInsensitiveDict with custom implementation --- decorest/__init__.py | 7 +++-- decorest/decorator_utils.py | 4 +-- decorest/decorators.py | 3 +- decorest/request.py | 10 +++--- decorest/types.py | 3 +- decorest/utils.py | 63 +++++++++++++++++++++++++++++++++++-- tests/decorators_tests.py | 36 ++++++++++++++++++++- tests/httpbin_async_test.py | 5 ++- tests/httpbin_test.py | 4 +-- 9 files changed, 113 insertions(+), 22 deletions(-) diff --git a/decorest/__init__.py b/decorest/__init__.py index 28878b0..1d9b065 100644 --- a/decorest/__init__.py +++ b/decorest/__init__.py @@ -28,12 +28,13 @@ from .errors import HTTPErrorWrapper from .request import HttpRequest from .types import HttpMethod, HttpStatus +from .utils import CaseInsensitiveDict __all__ = [ 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'RestClient', - 'HTTPErrorWrapper', 'HttpMethod', 'HttpStatus', 'HttpRequest', 'query', - 'body', 'header', 'on', 'accept', 'content', 'endpoint', 'timeout', - 'stream', 'form', 'multipart', 'backend' + 'HTTPErrorWrapper', 'HttpMethod', 'HttpStatus', 'HttpRequest', + 'CaseInsensitiveDict', 'query', 'body', 'header', 'on', 'accept', + 'content', 'endpoint', 'timeout', 'stream', 'form', 'multipart', 'backend' ] __version__ = "0.1.0" diff --git a/decorest/decorator_utils.py b/decorest/decorator_utils.py index 3b22495..e727daf 100644 --- a/decorest/decorator_utils.py +++ b/decorest/decorator_utils.py @@ -18,10 +18,8 @@ import numbers import typing -from requests.structures import CaseInsensitiveDict - from .types import Backends, HeaderDict, HttpMethod -from .utils import merge_dicts, merge_header_dicts +from .utils import CaseInsensitiveDict, merge_dicts, merge_header_dicts DECOR_KEY = '__decorest__' diff --git a/decorest/decorators.py b/decorest/decorators.py index d210b73..ebd317a 100644 --- a/decorest/decorators.py +++ b/decorest/decorators.py @@ -25,13 +25,12 @@ import typing from operator import methodcaller -from requests.structures import CaseInsensitiveDict - from . import types from .decorator_utils import set_decor, set_header_decor from .errors import HTTPErrorWrapper from .request import HttpRequest from .types import Backends, HttpMethod, HttpStatus, TDecor +from .utils import CaseInsensitiveDict def on(status: typing.Union[types.ellipsis, int], diff --git a/decorest/request.py b/decorest/request.py index 921091a..b2597a5 100644 --- a/decorest/request.py +++ b/decorest/request.py @@ -19,15 +19,13 @@ import numbers import typing -import requests -from requests.structures import CaseInsensitiveDict - from .client import RestClient from .decorator_utils import DECOR_LIST, get_body_decor, get_decor, \ get_header_decor, get_method_class_decor, get_method_decor, \ get_on_decor, get_stream_decor, get_timeout_decor from .errors import HTTPErrorWrapper from .types import ArgsDict, HTTPErrors, HttpMethod, HttpStatus +from .utils import CaseInsensitiveDict from .utils import dict_from_args, merge_dicts, render_path @@ -147,8 +145,9 @@ def __init__(self, func: typing.Callable[..., if decor in self.kwargs: if decor == 'header': self._validate_decor(decor, self.kwargs, dict) - header_parameters = merge_dicts( - header_parameters, self.kwargs['header']) + header_parameters \ + = typing.cast(CaseInsensitiveDict, merge_dicts( + header_parameters, self.kwargs['header'])) del self.kwargs['header'] elif decor == 'query': self._validate_decor(decor, self.kwargs, dict) @@ -235,6 +234,7 @@ def __init__(self, func: typing.Callable[..., self.execution_context = self.session else: if self.rest_client.backend_ == 'requests': + import requests self.execution_context = requests else: import httpx diff --git a/decorest/types.py b/decorest/types.py index a737985..d45a3a7 100644 --- a/decorest/types.py +++ b/decorest/types.py @@ -58,7 +58,8 @@ class HttpStatus(DIntEnum): import requests import httpx -ArgsDict = typing.Dict[str, typing.Any] +ArgsDict = typing.MutableMapping[str, typing.Any] +StrDict = typing.Mapping[str, typing.Any] Backends = typing_extensions.Literal['requests', 'httpx'] AuthTypes = typing.Union['requests.auth.AuthBase', 'httpx.AuthTypes'] HeaderDict = typing.Mapping[str, typing.Union[str, typing.List[str]]] diff --git a/decorest/utils.py b/decorest/utils.py index 046f4cc..4fafc5e 100644 --- a/decorest/utils.py +++ b/decorest/utils.py @@ -14,13 +14,72 @@ # See the License for the specific language governing permissions and # limitations under the License. """Utility functions.""" +import collections import copy import inspect import logging as LOG import re import typing -from decorest.types import ArgsDict +from decorest.types import ArgsDict, StrDict + + +class CaseInsensitiveDict(collections.abc.MutableMapping[str, typing.Any]): + """ + Case insensitive dict for storing header values. + + Mostly modeled after CaseInsensitiveDict in: + https://github.com/kennethreitz/requests + """ + __original: typing.MutableMapping[str, typing.Any] + + def __init__(self, data: StrDict = {}, **kwargs: typing.Any) -> None: + """Construct dict.""" + self.__original = dict() + self.update(data, **kwargs) + + def __setitem__(self, key: str, value: typing.Any) -> None: + """Set item under key.""" + self.__original[key.lower()] = (key, value) + + def __getitem__(self, key: str) -> typing.Any: + """Get item for key.""" + return self.__original[key.lower()][1] + + def __delitem__(self, key: str) -> None: + """Delete item with key.""" + del self.__original[key.lower()] + + def __contains__(self, key: str) -> bool: # type: ignore[override] + """Check if key exists.""" + return key.lower() in self.__original + + def __iter__(self) -> typing.Iterator[str]: + """Return key iterator.""" + return (stored_key for stored_key, _ in self.__original.values()) + + def __len__(self) -> int: + """Return number of keys.""" + return len(self.__original) + + def __eq__(self, d: StrDict) -> bool: # type: ignore[override] + """Compare with another dict.""" + if isinstance(d, collections.Mapping): + d = CaseInsensitiveDict(d) + else: + raise NotImplementedError + + return dict(self.iteritems_lower()) == dict(d.iteritems_lower()) + + def __repr__(self) -> str: + """Return dict representation.""" + return f'<{self.__class__.__name__} {dict(self.items())}>' + + def iteritems_lower(self) \ + -> typing.Iterable[typing.Tuple[str, typing.Any]]: + """Iterate over lower case keys.""" + return ((lkey, keyval[1]) + for (lkey, keyval) in self.__original.items()) def render_path(path: str, args: ArgsDict) -> str: @@ -60,7 +119,7 @@ def dict_from_args(func: typing.Callable[..., typing.Any], def merge_dicts(*dict_args: typing.Any) \ - -> typing.Dict[typing.Any, typing.Any]: + -> typing.MutableMapping[typing.Any, typing.Any]: """ Merge all dicts passed as arguments, skips None objects. diff --git a/tests/decorators_tests.py b/tests/decorators_tests.py index 037195d..8edabc5 100644 --- a/tests/decorators_tests.py +++ b/tests/decorators_tests.py @@ -21,7 +21,7 @@ from requests.auth import HTTPBasicAuth as r_HTTPBasicAuth from httpx import BasicAuth as x_HTTPBasicAuth -from decorest import RestClient, HttpMethod, HTTPErrorWrapper, multipart, on, HttpRequest +from decorest import RestClient, HttpMethod, HTTPErrorWrapper, multipart, on, HttpRequest, CaseInsensitiveDict from decorest import GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS from decorest import accept, backend, content, endpoint, form, header from decorest import query, stream @@ -29,6 +29,7 @@ get_endpoint_decor, get_form_decor, get_query_decor, \ get_stream_decor, get_on_decor, get_method_decor, get_method_class_decor, get_multipart_decor, get_accept_decor, \ get_content_decor, set_decor +from decorest.utils import merge_header_dicts @accept('application/json') @@ -119,6 +120,39 @@ def plain_stream_range(self, n: int, m: int, size: int, """Get data range""" +def test_case_insensitive_dict() -> None: + """ + Tests for case insensitive dict. + """ + d = CaseInsensitiveDict() + + d['Accept'] = 'application/json' + d['content-type'] = 'application/xml' + assert d['accept'] == 'application/json' + assert d['Content-Type'] == 'application/xml' + assert 'accept' in d + assert 'ACCEPT' in d + assert 'CONTENT-TYPE' in d + assert len(d) == 2 + + d1 = CaseInsensitiveDict(accept='application/json', allow='*') + assert d1['Accept'] == 'application/json' + assert d1['Allow'] == '*' + assert len(d1) == 2 + + ds = {'Content-TYPE': 'application/json', 'aCCEPT': 'application/xml'} + d2 = CaseInsensitiveDict(ds) + assert d2['content-type'] == 'application/json' + assert d2['Accept'] == 'application/xml' + assert len(d2) == 2 + + d3 = merge_header_dicts(d1, d2) + assert d3['content-type'] == 'application/json' + assert d3['accept'] == ['application/json', 'application/xml'] + assert d3['allow'] == '*' + assert len(d3) == 3 + + def test_set_decor() -> None: """ Check that decorators store proper values in the decorated diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index 34b97a2..21169fb 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -26,9 +26,8 @@ import httpx from httpx import BasicAuth -from requests.structures import CaseInsensitiveDict - -from decorest import __version__, HttpStatus, HTTPErrorWrapper +from decorest import __version__ +from decorest import CaseInsensitiveDict, HttpStatus, HTTPErrorWrapper from requests import cookies import xml.etree.ElementTree as ET diff --git a/tests/httpbin_test.py b/tests/httpbin_test.py index 4bb5191..e2937d3 100644 --- a/tests/httpbin_test.py +++ b/tests/httpbin_test.py @@ -23,9 +23,9 @@ import json import requests -from requests.structures import CaseInsensitiveDict -from decorest import __version__, HttpStatus, HTTPErrorWrapper +from decorest import __version__ +from decorest import CaseInsensitiveDict, HttpStatus, HTTPErrorWrapper from requests import cookies from requests.exceptions import ReadTimeout from requests.auth import HTTPBasicAuth, HTTPDigestAuth From 78bacc05e14540ca9613499438a003d195540db7 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Tue, 11 Jan 2022 23:01:42 +0100 Subject: [PATCH 41/48] Added DECOREST_BACKEND env var for selecting deps during install --- setup.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index b9581f0..db370c8 100644 --- a/setup.py +++ b/setup.py @@ -14,17 +14,30 @@ # See the License for the specific language governing permissions and # limitations under the License. """decorest - decorator heavy REST client library for Python.""" - - import os +import pathlib import re from setuptools import find_packages, setup +# Determine the dependencies based on preferred backend +# (i.e. requests[default] vs httpx) +backend_deps = ['requests', 'requests-toolbelt'] +backend_env = os.environ.get("DECOREST_BACKEND", None) +if backend_env: + if backend_env not in ['requests', 'httpx']: + raise ValueError(f'Invalid backend provided in ' + f'DECOREST_BACKEND: {backend_env}') + if backend_env == 'httpx': + backend_deps = ['httpx'] + def get_version(): """Return package version as listed in `__version__` in `init.py`.""" - init_py = open('decorest/__init__.py').read() + init_path = \ + str(pathlib.Path(pathlib.Path(__file__).parent.absolute(), + 'decorest/__init__.py')) + init_py = open(init_path).read() return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) @@ -65,6 +78,6 @@ def read(fname): 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10' ], - install_requires=['requests', 'requests-toolbelt'], + install_requires=backend_deps, tests_require=['pytest', 'tox', 'tox-docker'] ) \ No newline at end of file From b9822ebb32d22ed2faaae951729a1c06abff151b Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Wed, 12 Jan 2022 19:48:25 +0100 Subject: [PATCH 42/48] Replaced abc.MutableMapping with typing.MutableMapping --- decorest/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/decorest/utils.py b/decorest/utils.py index 4fafc5e..c34c5b8 100644 --- a/decorest/utils.py +++ b/decorest/utils.py @@ -24,7 +24,7 @@ from decorest.types import ArgsDict, StrDict -class CaseInsensitiveDict(collections.abc.MutableMapping[str, typing.Any]): +class CaseInsensitiveDict(typing.MutableMapping[str, typing.Any]): """ Case insensitive dict for storing header values. From b80daf91090fb27150a51c11353efc76b616794d Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Wed, 12 Jan 2022 21:58:25 +0100 Subject: [PATCH 43/48] Fixed dict typing for python 3.10 --- decorest/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/decorest/utils.py b/decorest/utils.py index c34c5b8..e8bacda 100644 --- a/decorest/utils.py +++ b/decorest/utils.py @@ -64,7 +64,7 @@ def __len__(self) -> int: def __eq__(self, d: StrDict) -> bool: # type: ignore[override] """Compare with another dict.""" - if isinstance(d, collections.Mapping): + if isinstance(d, collections.abc.Mapping): d = CaseInsensitiveDict(d) else: raise NotImplementedError From 79cde048f41473faedc143956e397fa04b8918e7 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Wed, 12 Jan 2022 22:26:18 +0100 Subject: [PATCH 44/48] Added codecov integration --- .github/workflows/workflow.yml | 6 +++++- codecov.yml | 0 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 codecov.yml diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index d31d971..a0577ac 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -28,4 +28,8 @@ jobs: python-version: ${{ matrix.python-version }} - run: pip install tox tox-gh-actions tox-docker==$TOX_DOCKER_VERSION - - run: tox -c tox.ini -e yapf,rstcheck,flake8,mypy,basic,swaggerpetstore,httpbin,asynchttpbin \ No newline at end of file + - run: tox -c tox.ini -e yapf,rstcheck,flake8,mypy,basic,swaggerpetstore,httpbin,asynchttpbin + - run: coverage xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 \ No newline at end of file diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..e69de29 From 85f9d3f6c6b40f21da9a482dba0d369c91c6f397 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Wed, 12 Jan 2022 22:33:16 +0100 Subject: [PATCH 45/48] Updated codecov integration --- .github/workflows/workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index a0577ac..736fd7c 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -27,7 +27,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - - run: pip install tox tox-gh-actions tox-docker==$TOX_DOCKER_VERSION + - run: pip install coverage tox tox-gh-actions tox-docker==$TOX_DOCKER_VERSION - run: tox -c tox.ini -e yapf,rstcheck,flake8,mypy,basic,swaggerpetstore,httpbin,asynchttpbin - run: coverage xml From ee22522685764bfac3fc44b1804490b60be29568 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Wed, 12 Jan 2022 22:43:23 +0100 Subject: [PATCH 46/48] Added codecov badge --- README.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.rst b/README.rst index 9a62d93..706b954 100644 --- a/README.rst +++ b/README.rst @@ -4,6 +4,9 @@ decorest - decorator heavy REST client for Python .. image:: https://github.com/bkryza/decorest/actions/workflows/workflow.yml/badge.svg :target: https://github.com/bkryza/decorest/actions/workflows/workflow.yml +.. image:: https://codecov.io/gh/bkryza/decorest/branch/master/graph/badge.svg?token=UGSU07W732 + :target: https://codecov.io/gh/bkryza/decorest + .. image:: https://img.shields.io/pypi/v/decorest.svg :target: https://pypi.python.org/pypi/decorest From 25764fceee23625e63e8b218bb17136e0698f8b6 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Wed, 12 Jan 2022 23:55:27 +0100 Subject: [PATCH 47/48] Updated README with async support --- README.rst | 211 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 178 insertions(+), 33 deletions(-) diff --git a/README.rst b/README.rst index 706b954..4348196 100644 --- a/README.rst +++ b/README.rst @@ -50,10 +50,31 @@ For example: print(client.list_subbreeds('hound')) +or for an async version (please note the :py:`async` keyword in the API method definition): + +.. code-block:: python + + import asyncio + from decorest import backend, RestClient, GET + + @backend('httpx') + class DogClient(RestClient): + @GET('breed/{breed_name}/list') + async def list_subbreeds(self, breed_name): + """List all sub-breeds""" + + async def main(): + client = DogClient('https://dog.ceo/api') + + print(await client.list_subbreeds('hound')) + + asyncio.run(main()) + + Installation ============ -**Note:** *As of version `0.1.0`, decorest_ supports only Python 3.6+.* +**Note:** *As of version `0.1.0`, decorest supports only Python 3.6+.* Using pip: @@ -61,6 +82,18 @@ Using pip: pip install decorest +To install the library with a specific backend, an environment variable must be provided, e.g.: + +.. code-block:: bash + + # This will only install requests and its dependencies (default) + DECOREST_BACKEND=requests pip install decorest + + # This will only install httpx and its dependencies + DECOREST_BACKEND=httpx pip install decorest + +Of course both requests_ and httpx_ can be installed together and used exchangeably. + Usage ===== @@ -72,7 +105,7 @@ subclass of :py:`decorest.RestClient` and define methods, which will perform cal to the actual REST service. You can declare how each function should perform the request to the service solely using decorators attached to the method definition. The method itself is not expected to have any implementation, -except maybe for a docstring. +except for a docstring. After your API client class definition is complete, simply create an instance of it and you're good to go. This library relies on the functionality provided @@ -95,9 +128,25 @@ To select a specific backend, simply pass it's name to the constructor of the cl client = DogClient('https://dog.ceo/api', backend='httpx') +Another option is to declare a specific default backend for the client using :py:`@backend()` +decorator, for instance: + +.. code-block:: python + + @decorest.backend('httpx') + class DogClient(decorest.RestClient): + @GET('breed/{breed_name}/list') + def list_subbreeds(self, breed_name): + """List all sub-breeds""" + + client = DogClient('https://dog.ceo/api') + If no backend is provided, requests_ is used by default. The client usage is largely independent of the backend, however there some minor differences in handling streams -and multipart messages, please consult tests in `httpbin test suite`_ and `httpx compatibility guide`_. +and multipart messages, please consult tests in `httpbin test suite`_ +and `httpx compatibility guide`_. + +Please note, that :py:`asyncio` is only supported on the httpx_ backend. Decorators ---------- @@ -274,10 +323,13 @@ decorators or as a decorator with a list of values, e.g.: def list_subbreeds(self, breed_name): """List all sub-breeds""" +Multiple values will be concatenated to a comma separated list and sent out +as a single header (according to the rfc2616_). + @body ~~~~~ -Body decorator enables to specify which of the method parameters should provide +Body decorator enables to specify, which of the method parameters should provide the body content to the request, e.g.: .. code-block:: python @@ -304,12 +356,12 @@ logic. For example: """Add a new pet to the store""" The above code will automatically stringify the dictionary provided as -value of 'pet' argument using :py:`json.dumps()` function. +value of :py:`pet` argument using :py:`json.dumps()` function. @on ~~~ -By default the request method will not return requests_ response object, +By default the request method will not return requests_ or httpx_ response object, but the response will depend on the content type of the response. In case the HTTP request succeeds the following results are expected: @@ -319,12 +371,12 @@ In case the HTTP request succeeds the following results are expected: - :py:`response.text` otherwise In case the request fails, :py:`response.raise_for_status()` is called and -should be handled in the code. +should be handled in the client code. In case another behavior is required, custom handlers can be provided for each method using lambdas or functions. The provided handler is -expected to take only a single argument, which is the requests_ response -object, e.g.: +expected to take only a single argument, which is the requests_ or httpx_ +response object, e.g.: .. code-block:: python @@ -335,10 +387,10 @@ object, e.g.: """List all sub-breeds""" This decorator can be applied to both methods and classes, however when -applied to a class the handler will be called for method which receives +applied to a class the handler will be called for the method which receives the provided status code. -The first argument of this decorator must be an integer. On Python 3 it +The first argument of this decorator must be an :py:`int`. It is also possible to pass :py:`...` (i.e. Ellipsis) object, which is equivalent to :py:`HttpStatus.ANY`. Any other value passed for this argument will raise :py:`TypeError`. @@ -405,7 +457,7 @@ however. @timeout ~~~~~~~~ -Specifies a default timeout value (in seconds) for method or entire API. +Specifies a default timeout value (in seconds) for a method or entire API. .. code-block:: python @@ -419,8 +471,8 @@ Specifies a default timeout value (in seconds) for method or entire API. ~~~~~~~ This decorator allows to specify a method which returns binary stream of data. Adding this decorator to a method will add a :py:`stream=True` -argument to the requests_ call and will by default return entire requests -object which then can be accessed for instance using :py:`iter_content()` method. +argument to the requests_ or httpx_ call and will by default returns entire response +object, which then can be accessed for instance using :py:`iter_content()` method. .. code-block:: python @@ -443,10 +495,35 @@ object which then can be accessed for instance using :py:`iter_content()` method content.append(b) +or for an async API: + +.. code-block:: python + + ... + + @backend('httpx') + class MyClient(RestClient): + ... + + @GET('stream/{n}/{m}') + @stream + @query('size') + @query('offset', 'off') + async def stream(self, n, m, size, offset): + """Get data range""" + + ... + async def main(): + async with client.async_session_() as s: + r = await s.stream(5) + async for _ in r.aiter_raw(chunk_size=100): + content.append(b) + + @backend ~~~~~~~~ Specifies the default backend to use by the client, currently the only possible -values are `requests` (default) and `httpx`, e.g.: +values are :py:`'requests'` (default) and :py:`'httpx'`, e.g.: .. code-block:: python @@ -462,7 +539,7 @@ over the value provided in this decorator. This decorator can only be applied to Sessions -------- -Based on the functionality provided by requests_ library in the form of +Based on the functionality provided by the backend HTTP library in the form of session objects, sessions can significantly improve the performance of the client in case multiple responses are performed as well as maintain certain information between requests such as session cookies. @@ -471,16 +548,16 @@ Sessions in decorest_ can either be created and closed manually: .. code-block:: python - s = client._session() + s = client.session_() s.list_subbreeds('hound') s.list_subbreeds('husky') - s._close() + s.close_() or can be used via the context manager :py:`with` operator: .. code-block:: python - with client._session() as s: + with client.session_() as s: s.list_subbreeds('hound') s.list_subbreeds('husky') @@ -489,16 +566,27 @@ to interfere with any possible API method names defined in the base client class. If some additional customization of the session is required, the underlying -`requests session`_ object can be retrieved from decorest_ session object -using :py:`_backend_session` attribute: +`requests session`_ or `httpx session`object can be retrieved from decorest_ +session object using :py:`backend_session_` attribute: .. code-block:: python - with client._session() as s: - s._backend_session.verify = '/path/to/cert.pem' + with client.session_() as s: + s.backend_session_.verify = '/path/to/cert.pem' s.list_subbreeds('hound') s.list_subbreeds('husky') +Async sessions can be created in a similar manner, using :py:`async_session_()` method, +for instance: + +.. code-block:: python + + async def main(): + async with client.async_session_() as s: + await s.list_subbreeds('hound') + await s.list_subbreeds('husky') + + Authentication -------------- @@ -506,21 +594,21 @@ Since authentication is highly specific to actual invocation of the REST API, and not to it's specification, there is not decorator for authentication, but instead an authentication object (compatible with `requests_` or `httpx_` authentication mechanism) can be set in the client object using -:py:`_set_auth()` method, for example: +:py:`set_auth_()` method, for example: .. code-block:: python - client._set_auth(HTTPBasicAuth('user', 'password')) - with client._session() as s: - s._backend_session.verify = '/path/to/cert.pem' + client.set_auth_(HTTPBasicAuth('user', 'password')) + with client.session_() as s: + s.backend_session_.verify = '/path/to/cert.pem' s.list_subbreeds('hound') s.list_subbreeds('husky') The authentication object will be used in both regular API calls, as well as when using sessions. -Furthermore, the `auth` object can be also passed to the client -constructor, e.g.: +Furthermore, the `auth` object - specific for selected backend - can be also +passed to the client constructor, e.g.: .. code-block:: python @@ -555,7 +643,63 @@ This can be achieved by creating separate client classes for each group of operations and then create a common class, which inherits from all the group clients and provides entire API from one instance. -For example of this checkout the `Petstore Swagger client example`_. +.. code-block:: python + + class A(RestClient): + """API One client""" + @GET('stuff/{sth}') + @on(200, lambda r: r.json()) + def get(self, sth: str) -> typing.Any: + """Get what""" + + + class B(RestClient): + """API One client""" + @PUT('stuff/{sth}') + @body('body') + @on(204, lambda _: True) + def put_b(self, sth: str, body: bytes) -> typing.Any: + """Put sth""" + + + @endpoint('https://put.example.com') + class BB(B): + """API One client""" + @PUT('stuff/{sth}') + @body('body') + @on(204, lambda _: True) + def put_bb(self, sth: str, body: bytes) -> typing.Any: + """Put sth""" + + + @endpoint('https://patches.example.com') + class C(RestClient): + """API Three client""" + @PATCH('stuff/{sth}') + @body('body') + @on(204, lambda _: True) + @on(..., lambda _: False) + def patch(self, sth: str, body: bytes) -> typing.Any: + """Patch sth""" + + + @accept('application/json') + @content('application/xml') + @header('X-Auth-Key', 'ABCD') + @endpoint('https://example.com') + @backend('httpx') + class InheritedClient(A, BB, C): + ... + + +Please note that the :py:`@endpoint()` decorator can be specified for each +sub API with a different value if necessary. It will be inherited by methods +backwards with respect to the inheritance chain, i.e. the more abstract class +will use the first endpoint specified in it's subclass chain. In the above example +method :py:`B.put_b()` will use :py:`'https://put.example.com'` endpoint, and +method :py:`C.patch()` will use :py:`'https://patches.example.com'`. + +For real world example checkout the `Petstore Swagger client example`_. Caveats @@ -590,7 +734,7 @@ module namespace: Compatibility with other decorators ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -In general the decorators should work with other decorators, which return +In general, decorest_ decorators should work with other decorators, which return function objects, but your mileage may vary. In general third-party decorators should be added above the HTTP method decorators as only the HTTP decorators make the actual HTTP request. Thus, typical decorators, which try to wrap @@ -628,7 +772,7 @@ tox_ and tox-docker_. .. code-block:: bash - python -m tox -e flake8,basic,httpbin,swaggerpetstore + python -m tox -e yapf,rstcheck,mypy,flake8,basic,httpbin,asynchttpbin,swaggerpetstore Checking README syntax @@ -665,4 +809,5 @@ limitations under the License. .. _`httpbin test suite`: https://github.com/bkryza/decorest/blob/master/tests/httpbin_test.py .. _tox: https://github.com/tox-dev/tox .. _tox-docker: https://github.com/tox-dev/tox-docker -.. _httpx compatibility guide: https://www.python-httpx.org/compatibility/ \ No newline at end of file +.. _httpx compatibility guide: https://www.python-httpx.org/compatibility/ +.. _rfc2616: https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html \ No newline at end of file From ac0e1ea88fc61fb731d7702e455e1b01bb7a8ee1 Mon Sep 17 00:00:00 2001 From: Bartek Kryza Date: Wed, 12 Jan 2022 23:55:41 +0100 Subject: [PATCH 48/48] Fixed typo in async tests --- tests/httpbin_async_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/httpbin_async_test.py b/tests/httpbin_async_test.py index 21169fb..4260ab8 100644 --- a/tests/httpbin_async_test.py +++ b/tests/httpbin_async_test.py @@ -237,7 +237,7 @@ async def test_multi_put(client): """ """ request_count = 100 - async with client._async_session() as s: + async with client.async_session_() as s: reqs = [ asyncio.ensure_future( s.put({i: str(i)}, content="application/json"))