diff --git a/.github/actions/python-build/action.yml b/.github/actions/python-build/action.yml new file mode 100644 index 0000000..c79cf70 --- /dev/null +++ b/.github/actions/python-build/action.yml @@ -0,0 +1,7 @@ +runs: + using: "composite" + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' \ No newline at end of file diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..ad0ea34 --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,102 @@ +name: http-toolkit-build-and-push +on: + workflow_run: + workflows: + - http-toolkit-test + types: + - completed + branches: + - 'main' + push: + tags: + - '*' +jobs: + build: + runs-on: ubuntu-latest + env: + BUILD_DEPENDENCIES: "build~=0.10.0" + steps: + - uses: actions/checkout@v4 + - name: python-build + uses: ./.github/actions/python-build + - name: install-dependencies + run: python3 -m pip install $BUILD_DEPENDENCIES $PUSH_DEPENDENCIES + - name: build + run: | + ls . + python -m build + - name: store-dist + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + publish-to-pypi: + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/http_toolkit + permissions: + id-token: write + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + check-release-exists: + needs: + - publish-to-pypi + runs-on: ubuntu-latest + outputs: + release-exists: ${{ steps.check-release.outputs.release-exists }} + steps: + - uses: actions/checkout@v4 + - name: Release Existence Action + id: check-release + uses: insightsengineering/release-existence-action@v1.0.0 + with: + release-tag: ${{github.ref_name}} + github-release: + name: >- + Sign the Python 🐍 distribution 📦 with Sigstore + and upload them to GitHub Release + needs: + - check-release-exists + runs-on: ubuntu-latest + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Sign the dists with Sigstore + uses: sigstore/gh-action-sigstore-python@v2.1.1 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Create GitHub Release + if: ${{ needs.check-release-exists.outputs.release-exists == 'false' }} + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release create + '${{ github.ref_name }}' + --repo '${{ github.repository }}' + --notes "" + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release upload + '${{ github.ref_name }}' dist/** + --repo '${{ github.repository }}' + + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9c6fc11 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,57 @@ +name: http-toolkit-test +on: [push] +env: + TEST_DEPENDENCIES: "tox~=4.15.1" +jobs: + check-project-files: + runs-on: ubuntu-latest + steps: + - name: variables + run: | + PROJECT_FILES=(README.md pyproject.toml tox.ini) + ERROR=0 + - name: test + run: | + for file in ${PROJECT_FILES[*]}; do + if ! [ -f $file ]; then + echo "$file not found in project" + ERROR=1 + fi + done + exit $ERROR + lint: + runs-on: ubuntu-latest + strategy: + matrix: + linters: [pyproject, flake8, mypy] + steps: + - uses: actions/checkout@v4 + - name: python-build + uses: ./.github/actions/python-build + - name: install-dependencies + run: | + python3 -m pip install --upgrade pip + pip install $TEST_DEPENDENCIES + - name: test + run: tox -e "${{ matrix.linters }}" + test: + runs-on: ubuntu-latest + strategy: + matrix: + minor_versions: [9, 10, 11, 12, 13] + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.${{ matrix.minor_versions }}' + - name: install-dependencies + run: | + python3 -m pip install --upgrade pip + pip install $TEST_DEPENDENCIES + - name: test + run: tox -e "py3${{ matrix.minor_versions }}" + + + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23a181c --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Created by https://www.gitignore.io/api/python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +### Python Patch ### +.venv/ + +### Python.VirtualEnv Stack ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +pip-selfcheck.json + + +### PyCharm ### +.idea/ + +# tox envname junit/coverage reports +/.reports diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c064d66 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +### 1.0.0 + +**Date:** [08.10.24] + +* Library for creating HTTP clients to various services with flexible transport customization diff --git a/README.md b/README.md index d7c1930..7ff7d44 100644 --- a/README.md +++ b/README.md @@ -1 +1,166 @@ -# http_toolkit \ No newline at end of file +# http_toolkit +The httptoolkit library is a tool for working with HTTP requests in Python that allows you to easily create, customize, and send requests to various services. It provides a simple interface for working with HTTP clients. The library also allows flexible transport customization and abstraction from a particular implementation of HTTP clients. + +## HTTPXService + +If you don't need to use a configured transport, use HTTPXService (for async -> AsyncHttpxService) + +```python +from httptoolkit import HttpxService +from httptoolkit import Header + +headers = ( + Header(name="My-Header", value="my-value", is_sensitive=False), +) +httpbin = HttpxService("http://httpbin.org", headers=headers) +httpbin.get("/get") +httpbin.post("/post") +``` + +## Service + +If you want to use a configured transport, use Service. (Service -> HttpxTransport, AsyncService -> AsyncHttpxTransport) +```python +### Sync + +from httptoolkit import Service, Header +from httptoolkit.transport import HttpxTransport + + +class DummyService(Service): + pass + + +DummyService( + headers=(Header(name="ServiceHeader", value="service-header", is_sensitive=False),), + transport=HttpxTransport(base_url="https://example.com:4321", proxies={"http://": "http://10.10.1.10:3128"}), + ## base_url in this case is passed to transport +) +``` + +```python +### Async + +from httptoolkit import AsyncService, Header +from httptoolkit.transport import AsyncHttpxTransport + + +class DummyService(AsyncService): + pass + + +DummyService( + headers=(Header(name="ServiceHeader", value="service-header", is_sensitive=False),), + transport=AsyncHttpxTransport(base_url="https://example.com:4321", proxies={"http://": "http://10.10.1.10:3128"}), + ## base_url in this case is passed to transport +) +``` + +### Sending a request + +```python + +from httptoolkit import Service, Header, HttpMethod +from httptoolkit.transport import HttpxTransport +from httptoolkit.request import Request + + +class DummyService(Service): + pass + + +service = DummyService( + headers=(Header(name="ServiceHeader", value="service-header", is_sensitive=False),), + transport=HttpxTransport(base_url="https://example.com:4321", proxies={"http://": "http://10.10.1.10:3128"}), + ## base_url in this case is passed to transport +) + +# By method +service.post( + path="/somewhere", + headers=(Header(name="SuperSecret", value="big_secret", is_sensitive=True, create_mask=lambda value: value[-4:]),), + params={"over": "the rainbow"}, + body="Something", +) + +# By request +service.request(Request(method=HttpMethod.POST, body="Request", params={}, path="")) +``` + +### Sending JSON and multipart-files + +```python +from httptoolkit import Service, Header +from httptoolkit.transport import HttpxTransport + + +class DummyService(Service): + pass + + +service = DummyService( + headers=(Header(name="ServiceHeader", value="service-header", is_sensitive=False),), + transport=HttpxTransport(base_url="https://example.com:4321", proxies={"http://": "http://10.10.1.10:3128"}), + ## base_url in this case is passed to transport +) + +# Send JSON (json_encoder is the default, but can be changed in transport) +# Do not send with body and files +service.post( + path="/somewhere", + headers=(Header(name="SuperSecret", value="big_secret", is_sensitive=True, create_mask=lambda value: value[-4:]),), + params={"over": "the rainbow"}, + json={ + "param1": 1, + "param2": 2, + }, +) + +# Send multipart-files in the format Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]] +# Can be sent with body, but cannot be sent with json +service.post( + path="/somewhere", + headers=(Header(name="SuperSecret", value="big_secret", is_sensitive=True, create_mask=lambda value: value[-4:]),), + params={"over": "the rainbow"}, + files={"upload-file": open("report.xls", "rb")}, + # different format files = {'upload-file': ('report.xls', open('report.xls', 'rb'), 'application/vnd.ms-excel')} +) +``` + +## The name of the library logger + +httptoolkit + +## Default logging level + +logging.INFO + +## Example of logging settings + +```python +import logging +import httptoolkit + +logging.basicConfig(level="INFO") + + +class MyService(httptoolkit.HttpxService): + def test(self): + self.get("/") + + +service = MyService("https://test.ru") + +service.test() +``` +## Output +```python +INFO:httptoolkit.transport._sync:Sending GET https://test.ru/ +``` + +All about Transport +- [HttpxTransport](https://github.com/skbkontur/http_toolkit/tree/master/docs/TRANSPORT.md#transport) +- [Creating your own Transport](https://github.com/skbkontur/http_toolkit/tree/master/docs/TRANSPORT.md#custom-transport) + +OpenTelemetry +Allows using [OpenTelemetry HTTPX](https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-httpx) to add request tracing via [http_toolkit](https://github.com/skbkontur/http_toolkit) in case [HttpxTransport](https://github.com/skbkontur/http_toolkit/tree/master/docs/TRANSPORT.md#transport) is used. diff --git a/docs/TRANSPORT.md b/docs/TRANSPORT.md new file mode 100644 index 0000000..02f225b --- /dev/null +++ b/docs/TRANSPORT.md @@ -0,0 +1,121 @@ +## Transport + +The configured transport is passed to the Service. HttpxTransport is an out-of-the-box implementation of an httpx-based transport, an instance of which you can pass to the Service. +## Example + +```python + +from httptoolkit import Service, Header +from httptoolkit.transport import HttpxTransport + + +class DummyService(Service): + pass + + +DummyService( + headers=(Header(name="ServiceHeader", value="service-header", is_sensitive=False),), + transport=HttpxTransport( + base_url="https://example.com:4321", + proxies={"http://": "http://10.10.1.10:3128"}, + # allow_unverified_peer: bool = DEFAULT_ALLOW_UNVERIFIED_PEER, + # open_timeout_in_seconds: float = DEFAULT_TIMEOUT_IN_SECONDS, + # read_timeout_in_seconds: float = DEFAULT_TIMEOUT_IN_SECONDS, + # retry_max_attempts: int = DEFAULT_RETRY_MAX_ATTEMPTS, + # retry_backoff_factor: float = DEFAULT_RETRY_BACKOFF_FACTOR, + # allow_post_retry: bool = True, + # json_encoder: Callable[[Any], str] = default_json_encoder, + ), + ## base_url in this case is passed to transport +) +``` + +## Custom Transport + +You can pass an instance of your own Transport class to Service by inheriting from the base class (Sync -> BaseTransport, Async -> BaseAsyncTransport) +```python +from typing import Tuple, Iterator +from httptoolkit import Service, Header +from httptoolkit.response import Response, StreamResponse +from httptoolkit.request import Request +from httptoolkit.sent_request import SentRequest +from httptoolkit.transport import BaseTransport + + +class DummyService(Service): + pass + + +class DummyTransport(BaseTransport): + + def send(self, request: Request) -> Tuple[SentRequest, Response]: + pass + + def stream(self, request: Request) -> Iterator[Tuple[SentRequest, StreamResponse]]: + pass + + +DummyService( + headers=(Header(name="ServiceHeader", value="service-header", is_sensitive=False),), + transport=DummyTransport( + # ... + ), +) +``` + +To customize retries in your own Transport class, you can use the RetryManager class + +```python +from contextlib import contextmanager +from typing import Iterator + +from httptoolkit.response import Response, StreamResponse +from httptoolkit.request import Request +from httptoolkit.transport import BaseTransport +from httptoolkit.retry import RetryManager + + +class DummyTransport(BaseTransport): + DEFAULT_EXCEPTIONS = (Exception1, Exception2) + DEFAULT_ALLOW_UNVERIFIED_PEER = False + DEFAULT_TIMEOUT_IN_SECONDS = 1 + DEFAULT_RETRY_MAX_ATTEMPTS = 10 + DEFAULT_RETRY_BACKOFF_FACTOR = 0.1 + DEFAULT_ALLOW_POST_RETRY = False + DEFAULT_DONT_RETRY_HEADERS = "Dont-Retry" + + def __init__( + self, + retry_max_attempts: int = DEFAULT_RETRY_MAX_ATTEMPTS, + retry_backoff_factor: float = DEFAULT_RETRY_BACKOFF_FACTOR, + allow_post_retry: bool = DEFAULT_ALLOW_POST_RETRY, + ) -> None: + method_whitelist = RetryManager.DEFAULT_METHODS + if allow_post_retry: + method_whitelist |= {"POST"} + self.retry_manager = RetryManager( + exceptions=self.DEFAULT_EXCEPTIONS, + max_attempts=retry_max_attempts, + backoff_factor=retry_backoff_factor, + methods=method_whitelist, + dont_retry_headers=self.DEFAULT_DONT_RETRY_HEADERS, + ) + + def send(self, request: Request) -> Response: + for retry in self._retry_manager.get_retries(request.method): + with retry: + response = ## get a response + retry.process_response(response) + return response + + # noinspection PyUnreachableCode + time.sleep(retry.backoff) + + @contextmanager + def stream(self, request: Request) -> Iterator[StreamResponse]: + response = self.send(request) + try: + yield response + finally: + response.close() +``` \ No newline at end of file diff --git a/httptoolkit/__init__.py b/httptoolkit/__init__.py new file mode 100644 index 0000000..5ffbfb1 --- /dev/null +++ b/httptoolkit/__init__.py @@ -0,0 +1,27 @@ +import logging + +from httptoolkit.errors import ServiceError, suppress_http_error +from httptoolkit.header import AuthSidHeader, BasicAuthHeader, BearerAuthHeader, Header +from httptoolkit.service import AsyncService, Service +from httptoolkit.httpx_service import HttpxService, AsyncHttpxService +from httptoolkit.http_method import HttpMethod + +__all__ = [ + "Service", + "AsyncService", + "HttpxService", + "AsyncHttpxService", + "ServiceError", + "suppress_http_error", + "Header", + "AuthSidHeader", + "BasicAuthHeader", + "BearerAuthHeader", + "HttpMethod", +] + +logger = logging.getLogger("httptoolkit") +logger.addHandler(logging.NullHandler()) +logger.setLevel(logging.INFO) +httpx_log = logging.getLogger("httpx") +httpx_log.propagate = False diff --git a/httptoolkit/_version.py b/httptoolkit/_version.py new file mode 100644 index 0000000..376abb2 --- /dev/null +++ b/httptoolkit/_version.py @@ -0,0 +1,5 @@ +from os import environ + +ref_type = environ.get("GITHUB_REF_TYPE") + +__version__ = environ.get("GITHUB_REF_NAME") if ref_type == "tag" else "0.dev0" diff --git a/httptoolkit/encoder.py b/httptoolkit/encoder.py new file mode 100644 index 0000000..a23682a --- /dev/null +++ b/httptoolkit/encoder.py @@ -0,0 +1,14 @@ +import json +import uuid +from typing import Any + + +class DefaultJSONEncoder(json.JSONEncoder): + def default(self, obj: Any) -> str: + if isinstance(obj, uuid.UUID): + return str(obj) + return super().default(obj) + + +def default_json_encoder(content: Any) -> str: + return json.dumps(content, cls=DefaultJSONEncoder) diff --git a/httptoolkit/errors.py b/httptoolkit/errors.py new file mode 100644 index 0000000..8c40a45 --- /dev/null +++ b/httptoolkit/errors.py @@ -0,0 +1,116 @@ +from contextlib import contextmanager +from typing import Optional + + +class Error(Exception): + def __str__(self): + context = self.__cause__ or self.__context__ + + if context: + return "{}: {}".format(type(context).__name__, context) + else: + return super().__str__() + + +class TransportError(Error): + def __init__(self, request) -> None: + self.request = request + + +class ServiceError(Error): + MESSAGE_DELIMITER = "\n" + + def __init__(self, request, response=None): + self._request = request + self._response = response + + def __str__(self): + description = self._description() + context = self.__cause__ or self.__context__ + + if context: + description = self._concatenate(str(context), description) + + return description + + @property + def response(self): + return self._response + + def response_code(self) -> Optional[int]: + if self._response is not None: + return self._response.status_code + return None + + def response_body(self) -> Optional[str]: + if self._response is not None: + return self._response.text + return None + + def _description(self): + return self._concatenate(self._request_description(), self._response_description()) + + def _request_description(self): + return self._concatenate( + "Request: {} {}".format(self._request.method.upper(), self._request.url), + "Request headers: {}".format(self._request.filtered_headers), + "Proxies: {}".format(self._request.proxies), + ) + + def _response_description(self): + if self._response is not None: + return self._concatenate( + "Response: {} {}".format(self.response_code(), self._response.reason), + "Response headers: {}".format(self._response.headers), + "Response body: {}".format(self.response_body()), + ) + + def __getstate__(self): + contexts = [self.__cause__ or self.__context__] + while contexts[len(contexts) - 1]: + last_context = contexts[len(contexts) - 1] + contexts.append(last_context.__context__ or last_context.__cause__) + return { + "contexts": contexts, + } + + def __setstate__(self, state): + contexts = state["contexts"] + self.__context__ = contexts[0] + context = self.__context__ + for i in range(0, len(contexts) - 1): + context.__context__ = contexts[i] + + def __reduce__(self): + return (self.__class__, (self._request, self._response), self.__getstate__()) + + def _concatenate(self, *strings): + return self.MESSAGE_DELIMITER.join(filter(None, strings)) + + +class HttpError(ServiceError): + def __init__(self, request, response): + super().__init__(request, response) + + +class HttpErrorTypecast: + HTTP_BAD_REQUEST_CODE = 400 + + @classmethod + def is_bad_request_error(cls, http_error): + return isinstance(http_error, Error) and http_error.response_code() == cls.HTTP_BAD_REQUEST_CODE + + +@contextmanager +def suppress_http_error(*statuses): + """ + Suppress http error with specified status codes. + + :param statuses: list of status codes + """ + try: + yield + except HttpError as e: + if e.response.status_code in statuses: + return None + raise diff --git a/httptoolkit/header.py b/httptoolkit/header.py new file mode 100644 index 0000000..f1a84f5 --- /dev/null +++ b/httptoolkit/header.py @@ -0,0 +1,88 @@ +from base64 import b64encode +from dataclasses import dataclass +from typing import Callable, Iterable, Optional, Set + + +class KnownSensitiveHeaders: + def __init__(self, items: Iterable[str] = []) -> None: + self.items: Set[str] = set() + self.update(items) + + def add(self, item: str) -> "KnownSensitiveHeaders": + return self.update([item]) + + def update(self, items: Iterable[str]) -> "KnownSensitiveHeaders": + self.items.update(map(str.casefold, items)) + return self + + def __contains__(self, item: str) -> bool: + return item.casefold() in self.items + + +known_sensitive_headers = KnownSensitiveHeaders(["Authorization"]) + + +@dataclass(frozen=True) +class Header: + """ + Custom HTTP-header. + """ + + name: str + value: str + is_sensitive: bool + create_mask: Optional[Callable[[str], str]] = None + + """Use True, if the header value contains sensitive data. Sensitive headers will not be + logged""" + + def __post_init__(self): + if not (self.is_sensitive or self.name in known_sensitive_headers) and self.create_mask: + raise RuntimeError( + "'.mask' may only be set if is_sensitive = True\nor name is in KNOWN_SENSITIVE_HEADERS_NAMES" + ) + + def __str__(self) -> str: + return f"{self.name}: {self.filtered_value}" + + @property + def filtered_value(self) -> str: + if self.is_sensitive or self.name in known_sensitive_headers: + if self.create_mask is not None: + return self.create_mask(self.value) + return "[filtered]" + return self.value + + +@dataclass(frozen=True) +class AuthSidHeader(Header): + def __init__(self, value: str, create_mask: Optional[Callable[[str], str]] = None) -> None: + super().__init__( + name="Authorization", + value=f"auth.sid {value}", + is_sensitive=True, + create_mask=create_mask, + ) + + +@dataclass(frozen=True) +class BearerAuthHeader(Header): + def __init__(self, value: str, create_mask: Optional[Callable[[str], str]] = None) -> None: + super().__init__( + name="Authorization", + value=f"Bearer {value}", + is_sensitive=True, + create_mask=create_mask, + ) + + +@dataclass(frozen=True) +class BasicAuthHeader(Header): + def __init__(self, username: str, password: str, create_mask: Optional[Callable[[str], str]] = None) -> None: + credentials = b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8") + super().__init__( + name="Authorization", + value=f"Basic {credentials}", + is_sensitive=True, + create_mask=create_mask, + ) diff --git a/httptoolkit/http_method.py b/httptoolkit/http_method.py new file mode 100644 index 0000000..1569d90 --- /dev/null +++ b/httptoolkit/http_method.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class HttpMethod(Enum): + GET = "GET" + DELETE = "DELETE" + PUT = "PUT" + PATCH = "PATCH" + POST = "POST" diff --git a/httptoolkit/httpx_service/__init__.py b/httptoolkit/httpx_service/__init__.py new file mode 100644 index 0000000..974c6ec --- /dev/null +++ b/httptoolkit/httpx_service/__init__.py @@ -0,0 +1,4 @@ +from ._sync import HttpxService +from ._async import AsyncHttpxService + +__all__ = ["AsyncHttpxService", "HttpxService"] diff --git a/httptoolkit/httpx_service/_async.py b/httptoolkit/httpx_service/_async.py new file mode 100644 index 0000000..6547ab0 --- /dev/null +++ b/httptoolkit/httpx_service/_async.py @@ -0,0 +1,14 @@ +from typing import Tuple + +from httptoolkit import Header +from httptoolkit.service import AsyncService +from httptoolkit.transport import AsyncHttpxTransport + + +class AsyncHttpxService(AsyncService): + def __init__( + self, + url: str, + headers: Tuple[Header, ...] = (), + ) -> None: + super().__init__(transport=AsyncHttpxTransport(url), headers=headers) diff --git a/httptoolkit/httpx_service/_sync.py b/httptoolkit/httpx_service/_sync.py new file mode 100644 index 0000000..26790e8 --- /dev/null +++ b/httptoolkit/httpx_service/_sync.py @@ -0,0 +1,14 @@ +from typing import Tuple + +from httptoolkit import Header +from httptoolkit.service import Service +from httptoolkit.transport import HttpxTransport + + +class HttpxService(Service): + def __init__( + self, + url: str, + headers: Tuple[Header, ...] = (), + ) -> None: + super().__init__(transport=HttpxTransport(url), headers=headers) diff --git a/httptoolkit/py.typed b/httptoolkit/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/httptoolkit/request.py b/httptoolkit/request.py new file mode 100644 index 0000000..53cf3ab --- /dev/null +++ b/httptoolkit/request.py @@ -0,0 +1,86 @@ +from typing import Dict, List, Optional, Tuple, Union, BinaryIO +from urllib.parse import urlencode + +from httptoolkit.header import Header +from httptoolkit.http_method import HttpMethod + + +class Request: + def __init__( + self, + method: HttpMethod, + path: str, + params: Optional[dict], + headers: Tuple[Header, ...] = (), + body: Optional[Union[bytes, str]] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ): + params = params if params is not None else {} + + self._headers: Tuple[Header, ...] = headers + + self._method = method + self._path = path + self._params = params + self._body = body + self._json = json + self._files = files + + def build_absolute_url(self, base_url: str) -> str: + return "/".join((base_url.rstrip("/"), self.full_path.lstrip("/"))) + + @property + def full_path(self) -> str: + if not self.params: + return self.path + else: + return f"{self.path}?{urlencode(self.params)}" + + def set_new_headers(self, new_headers: Tuple[Header, ...]) -> "Request": + return Request( + method=self._method, + path=self.path, + params=self.params, + headers=self.headers + new_headers, + body=self.body, + json=self.json, + files=self.files, + ) + + @property + def headers(self) -> Tuple[Header, ...]: + """ + :return: All service and request headers combined into a single tuple. + """ + return self._headers + + @property + def filtered_headers(self) -> Dict[str, str]: + headers = {header.name: header.filtered_value for header in self.headers} + + return headers + + @property + def body(self) -> Optional[Union[bytes, str]]: + return self._body + + @property + def method(self) -> str: + return self._method.value + + @property + def path(self) -> str: + return self._path + + @property + def params(self) -> Optional[dict]: + return self._params + + @property + def json(self) -> Optional[Union[dict, List]]: + return self._json + + @property + def files(self) -> Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]]: + return self._files diff --git a/httptoolkit/response/__init__.py b/httptoolkit/response/__init__.py new file mode 100644 index 0000000..94fd06a --- /dev/null +++ b/httptoolkit/response/__init__.py @@ -0,0 +1,5 @@ +from ._base import BaseResponse, OriginalResponse +from ._regular import Response +from ._stream import AsyncStreamResponse, StreamResponse + +__all__ = ["BaseResponse", "OriginalResponse", "Response", "StreamResponse", "AsyncStreamResponse"] diff --git a/httptoolkit/response/_base.py b/httptoolkit/response/_base.py new file mode 100644 index 0000000..9138de8 --- /dev/null +++ b/httptoolkit/response/_base.py @@ -0,0 +1,97 @@ +from datetime import timedelta +from typing import MutableMapping, Optional, Any, Iterator, AsyncIterator, Protocol, Callable + + +class OriginalResponse(Protocol): + @property + def is_success(self) -> bool: + pass # pragma: no cover + + @property + def status_code(self) -> int: + pass # pragma: no cover + + @property + def reason_phrase(self) -> str: + pass # pragma: no cover + + @property + def headers(self) -> MutableMapping[str, str]: + pass # pragma: no cover + + @property + def elapsed(self) -> timedelta: + pass # pragma: no cover + + @property + def content(self) -> bytes: + pass # pragma: no cover + + @property + def text(self) -> str: + pass # pragma: no cover + + def json( + self, + object_hook: Optional[Callable] = None, + parse_float: Optional[Callable] = None, + parse_int: Optional[Callable] = None, + parse_constant: Optional[Callable] = None, + object_pairs_hook: Optional[Callable] = None, + ) -> Any: + pass # pragma: no cover + + def iter_bytes(self, chunk_size: Optional[int] = None) -> Iterator[bytes]: + pass # pragma: no cover + + def iter_text(self, chunk_size: Optional[int] = None) -> Iterator[str]: + pass # pragma: no cover + + def iter_lines(self) -> Iterator[str]: + pass # pragma: no cover + + def iter_raw(self, chunk_size: Optional[int] = None) -> Iterator[bytes]: + pass # pragma: no cover + + def read(self) -> bytes: + pass # pragma: no cover + + def aiter_bytes(self, chunk_size: Optional[int] = None) -> AsyncIterator[bytes]: + pass # pragma: no cover + + def aiter_text(self, chunk_size: Optional[int] = None) -> AsyncIterator[str]: + pass # pragma: no cover + + def aiter_lines(self) -> AsyncIterator[str]: + pass # pragma: no cover + + def aiter_raw(self, chunk_size: Optional[int] = None) -> AsyncIterator[bytes]: + pass # pragma: no cover + + async def aread(self) -> bytes: + pass # pragma: no cover + + +class BaseResponse: + def __init__(self, response: OriginalResponse) -> None: + self._response = response + + @property + def ok(self) -> bool: + return self._response.is_success + + @property + def status_code(self) -> int: + return self._response.status_code + + @property + def reason(self) -> str: + return self._response.reason_phrase + + @property + def headers(self) -> MutableMapping[str, str]: + return self._response.headers + + @property + def elapsed(self) -> timedelta: + return self._response.elapsed diff --git a/httptoolkit/response/_regular.py b/httptoolkit/response/_regular.py new file mode 100644 index 0000000..bee5d89 --- /dev/null +++ b/httptoolkit/response/_regular.py @@ -0,0 +1,29 @@ +from typing import Any, Callable, Optional + +from ._base import BaseResponse + + +class Response(BaseResponse): + @property + def content(self) -> bytes: + return self._response.content + + @property + def text(self) -> str: + return self._response.text + + def json( + self, + object_hook: Optional[Callable] = None, + parse_float: Optional[Callable] = None, + parse_int: Optional[Callable] = None, + parse_constant: Optional[Callable] = None, + object_pairs_hook: Optional[Callable] = None, + ) -> Any: + return self._response.json( + object_hook=object_hook, + parse_float=parse_float, + parse_int=parse_int, + parse_constant=parse_constant, + object_pairs_hook=object_pairs_hook, + ) diff --git a/httptoolkit/response/_stream/__init__.py b/httptoolkit/response/_stream/__init__.py new file mode 100644 index 0000000..a536e91 --- /dev/null +++ b/httptoolkit/response/_stream/__init__.py @@ -0,0 +1,4 @@ +from ._async import AsyncStreamResponse +from ._sync import StreamResponse + +__all__ = ["StreamResponse", "AsyncStreamResponse"] diff --git a/httptoolkit/response/_stream/_async.py b/httptoolkit/response/_stream/_async.py new file mode 100644 index 0000000..63445c3 --- /dev/null +++ b/httptoolkit/response/_stream/_async.py @@ -0,0 +1,24 @@ +from typing import Optional, AsyncIterator + +from .._base import BaseResponse + + +class AsyncStreamResponse(BaseResponse): + def iter_bytes(self, chunk_size: Optional[int] = None) -> AsyncIterator[bytes]: + return self._response.aiter_bytes(chunk_size) + + def iter_text(self, chunk_size: Optional[int] = None) -> AsyncIterator[str]: + return self._response.aiter_text(chunk_size) + + def iter_lines(self) -> AsyncIterator[str]: + return self._response.aiter_lines() + + def iter_raw(self, chunk_size: Optional[int] = None) -> AsyncIterator[bytes]: + return self._response.aiter_raw(chunk_size) + + @property + def text(self) -> str: + return self._response.text + + async def read(self) -> bytes: + return await self._response.aread() diff --git a/httptoolkit/response/_stream/_sync.py b/httptoolkit/response/_stream/_sync.py new file mode 100644 index 0000000..8b56291 --- /dev/null +++ b/httptoolkit/response/_stream/_sync.py @@ -0,0 +1,24 @@ +from typing import Optional, Iterator + +from .._base import BaseResponse + + +class StreamResponse(BaseResponse): + def iter_bytes(self, chunk_size: Optional[int] = None) -> Iterator[bytes]: + return self._response.iter_bytes(chunk_size) + + def iter_text(self, chunk_size: Optional[int] = None) -> Iterator[str]: + return self._response.iter_text(chunk_size) + + def iter_lines(self) -> Iterator[str]: + return self._response.iter_lines() + + def iter_raw(self, chunk_size: Optional[int] = None) -> Iterator[bytes]: + return self._response.iter_raw(chunk_size) + + def read(self) -> bytes: + return self._response.read() + + @property + def text(self) -> str: + return self._response.text diff --git a/httptoolkit/retry.py b/httptoolkit/retry.py new file mode 100644 index 0000000..8704d1e --- /dev/null +++ b/httptoolkit/retry.py @@ -0,0 +1,112 @@ +import re +import time +from contextlib import suppress +from email.utils import mktime_tz, parsedate_tz +from typing import FrozenSet, Iterable, Iterator, Optional, Type + +from httptoolkit.response import OriginalResponse + + +class Retry(suppress): + class _RetryForResponseException(Exception): + def __init__(self, response: OriginalResponse) -> None: + super().__init__(response.reason_phrase) + + def __init__( + self, + ist_last: bool, + backoff: float, + exceptions: Iterable[Type[Exception]], + status_codes: FrozenSet[int], + dont_retry_headers: Iterable[str], + ) -> None: + super().__init__(self._RetryForResponseException, *exceptions) + + self._status_codes = status_codes + self._dont_retry_headers = dont_retry_headers + self._is_last = ist_last + self._backoff = backoff + self._response_backoff = 0.0 + + @property + def backoff(self) -> float: + return self._response_backoff or self._backoff + + def process_response(self, response: OriginalResponse) -> None: + response_headers = response.headers + dont_retry = "false" + for dont_retry_header in self._dont_retry_headers: + if response_headers.get(dont_retry_header, "").lower() == "true": + dont_retry = "true" + break + if dont_retry == "true": + return + + if not self._is_last and response.status_code in self._status_codes: + self._response_backoff = self._parse_retry_header(response_headers.get("Retry-After", "")) + raise self._RetryForResponseException(response) + + return + + @staticmethod + def _parse_retry_header(value: str) -> float: + if not value: + return 0 + + # Whitespace: https://tools.ietf.org/html/rfc7230#section-3.2.4 + if re.match(r"^\s*[0-9]+\s*$", value): + return max(0, int(value)) + + retry_date_tuple = parsedate_tz(value) + + if retry_date_tuple is None: + return 0 + + retry_date = mktime_tz(retry_date_tuple) + + return max(0, retry_date - time.time()) + + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + return not self._is_last and super().__exit__(exc_type, exc_val, exc_tb) + + +class RetryManager: + DEFAULT_METHODS = frozenset(["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"]) + DEFAULT_STATUS_CODES = frozenset([413, 429, 503]) + DEFAULT_BACKOFF_MAX = 120 + + def __init__( + self, + max_attempts: int, + backoff_factor: float, + exceptions: Iterable[Type[Exception]], + dont_retry_headers: Iterable[str], + backoff_max: float = DEFAULT_BACKOFF_MAX, + methods: Optional[Iterable[str]] = None, + status_codes: Iterable[int] = DEFAULT_STATUS_CODES, + ) -> None: + self._max_attempts = max_attempts + self._backoff_factor = backoff_factor + self._backoff_max = backoff_max + self._methods = frozenset(methods) if methods is not None else self.DEFAULT_METHODS + self._status_codes = frozenset(status_codes) + self._exceptions = exceptions + self.dont_retry_headers = dont_retry_headers + + def _get_exponential_backoff(self, index: int) -> float: + return min(self._backoff_max, self._backoff_factor * (2 ** (index - 1))) + + def _get_max_attempts_for_method(self, method: str) -> int: + return self._max_attempts if method in self._methods else 1 + + def get_retries(self, method: str) -> Iterator[Retry]: + max_attempts = self._get_max_attempts_for_method(method) + + for index in range(1, max_attempts + 1): + yield Retry( + ist_last=index >= max_attempts, + backoff=self._get_exponential_backoff(index), + exceptions=self._exceptions, + status_codes=self._status_codes, + dont_retry_headers=self.dont_retry_headers, + ) diff --git a/httptoolkit/sent_request.py b/httptoolkit/sent_request.py new file mode 100644 index 0000000..2d084da --- /dev/null +++ b/httptoolkit/sent_request.py @@ -0,0 +1,46 @@ +from typing import Dict, Optional, Tuple, Union + +from httptoolkit.header import Header +from httptoolkit.request import Request + + +class SentRequest: + def __init__( + self, + request: Request, + base_url: str, + body: Optional[Union[bytes, str]], + proxies: Optional[Dict] = None, + headers: Tuple[Header, ...] = (), + ): + self._request = request + self._url = self._request.build_absolute_url(base_url) + self._headers = headers + self._body = body + self._proxies = {} if proxies is None else proxies + + @property + def url(self) -> str: + return self._url + + @property + def headers(self) -> Tuple[Header, ...]: + return self._headers + + @property + def filtered_headers(self) -> Dict[str, str]: + headers = {header.name: header.filtered_value for header in self.headers} + + return headers + + @property + def body(self) -> Optional[Union[bytes, str]]: + return self._body + + @property + def method(self) -> str: + return self._request.method + + @property + def proxies(self) -> Optional[dict]: + return self._proxies diff --git a/httptoolkit/sent_request_log_record.py b/httptoolkit/sent_request_log_record.py new file mode 100644 index 0000000..7811217 --- /dev/null +++ b/httptoolkit/sent_request_log_record.py @@ -0,0 +1,28 @@ +from httptoolkit.sent_request import SentRequest + + +class RequestLogRecord: + _METHOD_TEMPLATES = {"get": "Sending {method} {url}"} + _DEFAULT_TEMPLATE = "Sending {method} {url} (body: {body_size})" + + def __init__(self, request: SentRequest) -> None: + self._request = request + + def __str__(self) -> str: + return self._template.format(**self.args()) + + @property + def _url(self) -> str: + return self._request.url + + def args(self) -> dict: + return { + "method": self._request.method.upper(), + "url": self._url, + "headers": "\n".join(str(header) for header in self._request.headers), + "body_size": 0 if not self._request.body else len(str(self._request.body)), + } + + @property + def _template(self) -> str: + return self._METHOD_TEMPLATES.get(self._request.method.lower(), self._DEFAULT_TEMPLATE) diff --git a/httptoolkit/service/__init__.py b/httptoolkit/service/__init__.py new file mode 100644 index 0000000..f2cdecc --- /dev/null +++ b/httptoolkit/service/__init__.py @@ -0,0 +1,4 @@ +from ._async import AsyncService +from ._sync import Service + +__all__ = ["Service", "AsyncService"] diff --git a/httptoolkit/service/_async.py b/httptoolkit/service/_async.py new file mode 100644 index 0000000..011d9b1 --- /dev/null +++ b/httptoolkit/service/_async.py @@ -0,0 +1,262 @@ +from contextlib import asynccontextmanager, contextmanager +from typing import Optional, AsyncIterator, Union, Tuple, List, Dict, BinaryIO, Iterator + +from httptoolkit.errors import HttpError, TransportError, ServiceError +from httptoolkit.header import Header +from httptoolkit.request import Request +from httptoolkit.response import AsyncStreamResponse, BaseResponse, Response +from httptoolkit.http_method import HttpMethod +from httptoolkit.sent_request import SentRequest +from httptoolkit.transport import BaseAsyncTransport + + +class AsyncService: + def __init__( + self, + transport: BaseAsyncTransport, + headers: Tuple[Header, ...] = (), + ) -> None: + self._transport = transport + self._headers: Tuple[Header, ...] = headers + + @property + def headers(self) -> Tuple[Header, ...]: + return self._headers + + async def request( + self, + request: Request, + ) -> Response: + with self._managed_transport() as async_transport: + sent_request, response = await async_transport.send(request) + await self._validate_response(sent_request, response) + return response + + async def post( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> Response: + request = Request( + method=HttpMethod.POST, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + return await self.request(request) + + async def patch( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> Response: + request = Request( + method=HttpMethod.PATCH, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + return await self.request(request) + + async def put( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> Response: + request = Request( + method=HttpMethod.PUT, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + return await self.request(request) + + async def delete( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> Response: + request = Request( + method=HttpMethod.DELETE, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + return await self.request(request) + + async def get( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + ) -> Response: + request = Request( + method=HttpMethod.GET, + path=path, + headers=self.headers + headers, + params=params, + body=None, + json=None, + files=None, + ) + return await self.request(request) + + @staticmethod + async def _validate_response(sent_request: SentRequest, response: BaseResponse) -> None: + if not response.ok: + if isinstance(response, AsyncStreamResponse): + await response.read() + raise HttpError(sent_request, response) + + @asynccontextmanager + async def stream_request( + self, + request: Request, + ) -> AsyncIterator[AsyncStreamResponse]: + with self._managed_transport() as transport: + async with transport.stream(request) as (sent_request, async_stream_response): + await self._validate_response(sent_request, async_stream_response) + yield async_stream_response + + @asynccontextmanager + async def post_stream( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> AsyncIterator[AsyncStreamResponse]: + request = Request( + method=HttpMethod.POST, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + async with self.stream_request(request) as async_stream_response: + yield async_stream_response + + @asynccontextmanager + async def patch_stream( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> AsyncIterator[AsyncStreamResponse]: + request = Request( + method=HttpMethod.PATCH, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + async with self.stream_request(request) as async_stream_response: + yield async_stream_response + + @asynccontextmanager + async def put_stream( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> AsyncIterator[AsyncStreamResponse]: + request = Request( + method=HttpMethod.PUT, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + async with self.stream_request(request) as async_stream_response: + yield async_stream_response + + @asynccontextmanager + async def delete_stream( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> AsyncIterator[AsyncStreamResponse]: + request = Request( + method=HttpMethod.DELETE, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + async with self.stream_request(request) as async_stream_response: + yield async_stream_response + + @asynccontextmanager + async def get_stream( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + ) -> AsyncIterator[AsyncStreamResponse]: + request = Request( + method=HttpMethod.GET, + path=path, + headers=self.headers + headers, + params=params, + body=None, + json=None, + files=None, + ) + async with self.stream_request(request) as async_stream_response: + yield async_stream_response + + @contextmanager + def _managed_transport(self) -> Iterator[BaseAsyncTransport]: + try: + yield self._transport + + except TransportError as exc: + raise ServiceError(exc.request) from exc diff --git a/httptoolkit/service/_sync.py b/httptoolkit/service/_sync.py new file mode 100644 index 0000000..4510188 --- /dev/null +++ b/httptoolkit/service/_sync.py @@ -0,0 +1,260 @@ +from contextlib import contextmanager +from typing import Iterator, List, Optional, Tuple, Union, Dict, BinaryIO + +from httptoolkit.errors import HttpError, TransportError, ServiceError +from httptoolkit.header import Header +from httptoolkit.request import Request +from httptoolkit.response import BaseResponse, Response, StreamResponse + +from httptoolkit.sent_request import SentRequest +from httptoolkit.http_method import HttpMethod +from httptoolkit.transport import BaseTransport + + +class Service: + def __init__( + self, + transport: BaseTransport, + headers: Tuple[Header, ...] = (), + ) -> None: + self._transport = transport + self._headers: Tuple[Header, ...] = headers + + @property + def headers(self) -> Tuple[Header, ...]: + return self._headers + + def request(self, request: Request) -> Response: + with self._managed_transport() as transport: + sent_request, response = transport.send(request) + self._validate_response(sent_request, response) + return response + + def post( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> Response: + request = Request( + method=HttpMethod.POST, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + return self.request(request) + + def patch( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> Response: + request = Request( + method=HttpMethod.PATCH, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + return self.request(request) + + def put( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> Response: + request = Request( + method=HttpMethod.PUT, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + return self.request(request) + + def delete( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> Response: + request = Request( + method=HttpMethod.DELETE, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + return self.request(request) + + def get( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + ) -> Response: + request = Request( + method=HttpMethod.GET, + path=path, + headers=self.headers + headers, + params=params, + body=None, + json=None, + files=None, + ) + return self.request(request) + + @staticmethod + def _validate_response(sent_request: SentRequest, response: BaseResponse) -> None: + if not response.ok: + if isinstance(response, StreamResponse): + response.read() + raise HttpError(sent_request, response) + + @contextmanager + def stream_request( + self, + request: Request, + ) -> Iterator[StreamResponse]: + with self._managed_transport() as transport: + with transport.stream(request) as (sent_request, stream_response): + self._validate_response(sent_request, stream_response) + yield stream_response + + @contextmanager + def post_stream( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> Iterator[StreamResponse]: + request = Request( + method=HttpMethod.POST, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + with self.stream_request(request) as stream_response: + yield stream_response + + @contextmanager + def patch_stream( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> Iterator[StreamResponse]: + request = Request( + method=HttpMethod.PATCH, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + with self.stream_request(request) as stream_response: + yield stream_response + + @contextmanager + def put_stream( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> Iterator[StreamResponse]: + request = Request( + method=HttpMethod.PUT, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + with self.stream_request(request) as stream_response: + yield stream_response + + @contextmanager + def delete_stream( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + body: Optional[str] = None, + json: Optional[Union[dict, List]] = None, + files: Optional[Dict[str, Union[BinaryIO, Tuple[str, BinaryIO, str]]]] = None, + ) -> Iterator[StreamResponse]: + request = Request( + method=HttpMethod.DELETE, + path=path, + headers=self.headers + headers, + params=params, + body=body, + json=json, + files=files, + ) + with self.stream_request(request) as stream_response: + yield stream_response + + @contextmanager + def get_stream( + self, + path: str, + headers: Tuple[Header, ...] = (), + params: Optional[dict] = None, + ) -> Iterator[StreamResponse]: + request = Request( + method=HttpMethod.GET, + path=path, + headers=self.headers + headers, + params=params, + body=None, + json=None, + files=None, + ) + with self.stream_request(request) as stream_response: + yield stream_response + + @contextmanager + def _managed_transport(self) -> Iterator[BaseTransport]: + try: + yield self._transport + + except TransportError as exc: + raise ServiceError(exc.request) from exc diff --git a/httptoolkit/transport/__init__.py b/httptoolkit/transport/__init__.py new file mode 100644 index 0000000..6ee2777 --- /dev/null +++ b/httptoolkit/transport/__init__.py @@ -0,0 +1,13 @@ +from ._sync_base import BaseTransport +from ._async_base import BaseAsyncTransport +from ._httpx._base import BaseHttpxTransport +from ._httpx._sync import HttpxTransport +from ._httpx._async import AsyncHttpxTransport + +__all__ = [ + "BaseTransport", + "BaseAsyncTransport", + "BaseHttpxTransport", + "HttpxTransport", + "AsyncHttpxTransport", +] diff --git a/httptoolkit/transport/_async_base.py b/httptoolkit/transport/_async_base.py new file mode 100644 index 0000000..34a85c3 --- /dev/null +++ b/httptoolkit/transport/_async_base.py @@ -0,0 +1,18 @@ +from abc import abstractmethod, ABC +from contextlib import asynccontextmanager +from typing import Tuple, AsyncIterator + +from httptoolkit.request import Request +from httptoolkit.response import Response, AsyncStreamResponse +from httptoolkit.sent_request import SentRequest + + +class BaseAsyncTransport(ABC): + @abstractmethod + async def send(self, request: Request) -> Tuple[SentRequest, Response]: # pragma: no cover + pass + + @asynccontextmanager + @abstractmethod + def stream(self, request: Request) -> AsyncIterator[Tuple[SentRequest, AsyncStreamResponse]]: # pragma: no cover + pass diff --git a/httptoolkit/transport/_httpx/__init__.py b/httptoolkit/transport/_httpx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/httptoolkit/transport/_httpx/_async.py b/httptoolkit/transport/_httpx/_async.py new file mode 100644 index 0000000..43637c2 --- /dev/null +++ b/httptoolkit/transport/_httpx/_async.py @@ -0,0 +1,28 @@ +from contextlib import asynccontextmanager +from typing import AsyncIterator, Tuple + +from httptoolkit.request import Request +from httptoolkit.response import Response, AsyncStreamResponse +from httptoolkit.transport._httpx._session._async import AsyncHttpxSession + +from httptoolkit.sent_request import SentRequest +from ._base import BaseHttpxTransport +from httptoolkit.transport import BaseAsyncTransport + + +class AsyncHttpxTransport(BaseAsyncTransport, BaseHttpxTransport): + _session_class = AsyncHttpxSession + + async def send(self, request: Request) -> Tuple[SentRequest, Response]: + httpx_request = self._build_httpx_request(request) + sent_request = self._prepare_sent_request(request, httpx_request) + with self._managed_session(sent_request) as async_session: + return sent_request, Response(await async_session.send(httpx_request)) + + @asynccontextmanager + async def stream(self, request: Request) -> AsyncIterator[Tuple[SentRequest, AsyncStreamResponse]]: + httpx_request = self._build_httpx_request(request) + sent_request = self._prepare_sent_request(request, httpx_request) + with self._managed_session(sent_request) as async_session: + async with async_session.stream(httpx_request) as response: + yield sent_request, AsyncStreamResponse(response) diff --git a/httptoolkit/transport/_httpx/_base.py b/httptoolkit/transport/_httpx/_base.py new file mode 100644 index 0000000..7a1c3da --- /dev/null +++ b/httptoolkit/transport/_httpx/_base.py @@ -0,0 +1,147 @@ +import logging +from abc import abstractmethod, ABC +from contextlib import contextmanager +from typing import Any, Callable, Iterator, List, Optional, Tuple, Type, Union, Iterable + +from httpx import Request as OriginalRequest, ConnectError, ConnectTimeout + +from httptoolkit.encoder import default_json_encoder +from httptoolkit.errors import TransportError +from httptoolkit.header import Header +from httptoolkit.request import Request +from httptoolkit.retry import RetryManager +from httptoolkit.sent_request import SentRequest +from httptoolkit.sent_request_log_record import RequestLogRecord + + +class BaseHttpxTransport(ABC): + DEFAULT_EXCEPTIONS = (ConnectError, ConnectTimeout) + DEFAULT_DONT_RETRY_HEADERS = "Dont-Retry" + DEFAULT_ALLOW_UNVERIFIED_PEER = False + DEFAULT_TIMEOUT_IN_SECONDS = 1 + DEFAULT_RETRY_MAX_ATTEMPTS = 10 + DEFAULT_RETRY_BACKOFF_FACTOR = 0.1 + DEFAULT_ALLOW_POST_RETRY = False + + def __init__( + self, + base_url: str, + allow_unverified_peer: bool = DEFAULT_ALLOW_UNVERIFIED_PEER, + open_timeout_in_seconds: float = DEFAULT_TIMEOUT_IN_SECONDS, + read_timeout_in_seconds: float = DEFAULT_TIMEOUT_IN_SECONDS, + retry_max_attempts: int = DEFAULT_RETRY_MAX_ATTEMPTS, + retry_backoff_factor: float = DEFAULT_RETRY_BACKOFF_FACTOR, + allow_post_retry: bool = DEFAULT_ALLOW_POST_RETRY, + proxies: Optional[dict] = None, + json_encoder: Callable[[Any], str] = default_json_encoder, + retry_status_codes: Iterable[int] = RetryManager.DEFAULT_STATUS_CODES, + ) -> None: + if proxies is None: + proxies = {} + method_whitelist = RetryManager.DEFAULT_METHODS + + if allow_post_retry: + method_whitelist |= {"POST"} + + self._base_url = base_url + self._open_timeout_in_seconds = open_timeout_in_seconds + self._read_timeout_in_seconds = read_timeout_in_seconds + self._proxies = proxies + self._session = self._session_class( + retry_manager=RetryManager( + exceptions=self.DEFAULT_EXCEPTIONS, + max_attempts=retry_max_attempts, + backoff_factor=retry_backoff_factor, + methods=method_whitelist, + dont_retry_headers=self.DEFAULT_DONT_RETRY_HEADERS, + status_codes=retry_status_codes, + ), + allow_unverified_peer=allow_unverified_peer, + proxies=self._proxies, + ) + self._logger = logging.getLogger(self.__class__.__module__) + self._json_encoder = json_encoder + + @property + @abstractmethod + def _session_class(self) -> Type[Any]: # pragma: no cover + pass + + def _build_httpx_request( + self, + request: Request, + ) -> OriginalRequest: + headers, content, data = self._prepare_content(request) + dict_headers = {header.name.lower(): header.value for header in headers + request.headers} + httpx_request = self._session.build_request( + method=request.method, + url=request.build_absolute_url(self._base_url), + headers=dict_headers, + content=content, + files=request.files, + data=data, + extensions={ + "timeout": { + "connect": self._open_timeout_in_seconds, + "read": self._read_timeout_in_seconds, + "write": None, + "pool": None, + } + }, + ) + return httpx_request + + @contextmanager + def _managed_session(self, request: SentRequest) -> Iterator[Any]: + self._log(request) + + try: + yield self._session + + except Exception as exc: + raise TransportError(request) from exc + + def _encode_json(self, request_json: Union[dict, List]) -> Tuple[Tuple[Header, ...], Union[bytes, str]]: + body = self._json_encoder(request_json) + headers = (Header(name="Content-Type", value="application/json", is_sensitive=False),) + return headers, body + + def _prepare_content( + self, + request: Request, + ) -> Tuple[Tuple[Header, ...], Optional[Union[bytes, str]], Optional[dict]]: + if request.files is not None: + if request.json is not None: + raise RuntimeError("json and files can't be sent together") + if request.body is not None: + return (), None, {"data": request.body} + return (), None, None + if request.json is not None: + if request.body is not None: + raise RuntimeError("json and body can't be sent together") + return *self._encode_json(request.json), None + return (), request.body, None + + def _prepare_sent_request( + self, + request: Request, + httpx_request: OriginalRequest, + ) -> SentRequest: + set_headers_with_sensitive = {header.name.lower(): header.is_sensitive for header in request.headers} + httpx_request.read() + content = httpx_request.content + httpx_headers = tuple( + Header( + name=header_name, + value=httpx_request.headers[header_name], + is_sensitive=set_headers_with_sensitive.get(header_name, False), + ) + for header_name in httpx_request.headers + ) + return SentRequest( + request=request, base_url=self._base_url, body=content, proxies=self._proxies, headers=httpx_headers + ) + + def _log(self, request: SentRequest) -> None: + request_log_record = RequestLogRecord(request) + self._logger.info(request_log_record, extra=request_log_record.args()) diff --git a/httptoolkit/transport/_httpx/_session/__init__.py b/httptoolkit/transport/_httpx/_session/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/httptoolkit/transport/_httpx/_session/_async.py b/httptoolkit/transport/_httpx/_session/_async.py new file mode 100644 index 0000000..1d6d828 --- /dev/null +++ b/httptoolkit/transport/_httpx/_session/_async.py @@ -0,0 +1,44 @@ +import asyncio +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional + +from httpx import AsyncClient, AsyncHTTPTransport +from httpx import Request as OriginalHttpxRequest +from httpx import Response as OriginalHttpxResponse +from httptoolkit.retry import RetryManager + + +class AsyncHttpxSession(AsyncClient): + def __init__( + self, + retry_manager: RetryManager, + allow_unverified_peer: Optional[bool] = False, + proxies: Optional[dict] = None, + ) -> None: + super().__init__( + transport=AsyncHTTPTransport( + verify=not allow_unverified_peer, + ), + proxies=proxies, + ) + + self._retry_manager = retry_manager + + async def send(self, request: OriginalHttpxRequest, *args, **kwargs) -> OriginalHttpxResponse: + for retry in self._retry_manager.get_retries(request.method): + with retry: + response = await super().send(request, *args, **kwargs) + retry.process_response(response) + return response + + # noinspection PyUnreachableCode + await asyncio.sleep(retry.backoff) + + @asynccontextmanager + async def stream(self, request: OriginalHttpxRequest, *args, **kwargs) -> AsyncIterator[OriginalHttpxResponse]: + kwargs.update(request=request, stream=True) + response = await self.send(*args, **kwargs) + try: + yield response + finally: + await response.aclose() diff --git a/httptoolkit/transport/_httpx/_session/_sync.py b/httptoolkit/transport/_httpx/_session/_sync.py new file mode 100644 index 0000000..ad8d46e --- /dev/null +++ b/httptoolkit/transport/_httpx/_session/_sync.py @@ -0,0 +1,44 @@ +import time +from contextlib import contextmanager +from typing import Iterator, Optional + +from httpx import Client, HTTPTransport +from httpx import Request as OriginalHttpxRequest +from httpx import Response as OriginalHttpxResponse +from httptoolkit.retry import RetryManager + + +class HttpxSession(Client): + def __init__( + self, + retry_manager: RetryManager, + allow_unverified_peer: Optional[bool] = False, + proxies: Optional[dict] = None, + ) -> None: + super().__init__( + transport=HTTPTransport( + verify=not allow_unverified_peer, + ), + proxies=proxies, + ) + + self._retry_manager = retry_manager + + def send(self, request: OriginalHttpxRequest, *args, **kwargs) -> OriginalHttpxResponse: + for retry in self._retry_manager.get_retries(request.method): + with retry: + response = super().send(request, *args, **kwargs) + retry.process_response(response) + return response + + # noinspection PyUnreachableCode + time.sleep(retry.backoff) + + @contextmanager + def stream(self, request: OriginalHttpxRequest, *args, **kwargs) -> Iterator[OriginalHttpxResponse]: + kwargs.update(request=request, stream=True) + response = self.send(*args, **kwargs) + try: + yield response + finally: + response.close() diff --git a/httptoolkit/transport/_httpx/_sync.py b/httptoolkit/transport/_httpx/_sync.py new file mode 100644 index 0000000..dab3fab --- /dev/null +++ b/httptoolkit/transport/_httpx/_sync.py @@ -0,0 +1,28 @@ +from contextlib import contextmanager +from typing import Iterator, Tuple + +from httptoolkit.request import Request +from httptoolkit.response import Response, StreamResponse +from httptoolkit.transport._httpx._session._sync import HttpxSession + +from httptoolkit.sent_request import SentRequest +from ._base import BaseHttpxTransport +from httptoolkit.transport import BaseTransport + + +class HttpxTransport(BaseTransport, BaseHttpxTransport): + _session_class = HttpxSession + + def send(self, request: Request) -> Tuple[SentRequest, Response]: + httpx_request = self._build_httpx_request(request) + sent_request = self._prepare_sent_request(request, httpx_request) + with self._managed_session(sent_request) as session: + return sent_request, Response(session.send(httpx_request)) + + @contextmanager + def stream(self, request: Request) -> Iterator[Tuple[SentRequest, StreamResponse]]: + httpx_request = self._build_httpx_request(request) + sent_request = self._prepare_sent_request(request, httpx_request) + with self._managed_session(sent_request) as session: + with session.stream(httpx_request) as response: + yield sent_request, StreamResponse(response) diff --git a/httptoolkit/transport/_sync_base.py b/httptoolkit/transport/_sync_base.py new file mode 100644 index 0000000..66eb005 --- /dev/null +++ b/httptoolkit/transport/_sync_base.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod +from contextlib import contextmanager +from typing import Tuple, Iterator + +from httptoolkit.request import Request +from httptoolkit.response import Response, StreamResponse +from httptoolkit.sent_request import SentRequest + + +class BaseTransport(ABC): + @abstractmethod + def send(self, request: Request) -> Tuple[SentRequest, Response]: # pragma: no cover + pass + + @abstractmethod + @contextmanager + def stream(self, request: Request) -> Iterator[Tuple[SentRequest, StreamResponse]]: # pragma: no cover + pass diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..18ce175 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = ["setuptools~=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "http_toolkit" +authors = [{name="Kontur", email = "python@skbkontur.ru"}] +description = "Library for creating HTTP clients to various services with flexible transport customization" +readme = "README.md" +requires-python = ">=3.9, <3.14" +dependencies = ["httpx~=0.24.1"] +dynamic = ["version"] # The version is imported from the module attribute specified in section [tool.setuptools.dynamic] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: AsyncIO", + "Framework :: tox", + "Intended Audience :: Information Technology", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: System :: Networking", + "Topic :: Internet :: WWW/HTTP", +] + +[project.optional-dependencies] +instruments = ["httpx~=0.24.1"] +test = ["pytest==7.0.0", "pytest-cov==3.0.0", "testfixtures==6.18.3", "pytest-asyncio==0.18.3", "pytest-httpx==0.22.0"] + +[project.urls] +homepage = "https://github.com/skbkontur/http_toolkit" + +[tool.pytest.ini_options] +addopts = "-ra -q" + +[tool.flake8] +max-line-length = 119 +exclude = ".svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg,.venv,env,venv,build" +extend-ignore = ["E203"] +tee = true + +[tool.black] +line-length = 119 +target-version = ["py39"] + +[tool.mypy] +ignore_missing_imports = true +explicit_package_bases = true +install_types = true +non_interactive = true +exclude = ["env", "venv", "build"] + +[tool.setuptools.packages.find] +include = ["httptoolkit"] + +[tool.setuptools.dynamic] +version = {attr = "httptoolkit._version.__version__"} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/httptoolkit/__init__.py b/tests/httptoolkit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/httptoolkit/conftest.py b/tests/httptoolkit/conftest.py new file mode 100644 index 0000000..79f43df --- /dev/null +++ b/tests/httptoolkit/conftest.py @@ -0,0 +1,222 @@ +import datetime +import decimal +import os +from datetime import date +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Tuple, Type, BinaryIO, Set + +import pytest +from _decimal import Decimal + +from httptoolkit import Header +from httptoolkit.encoder import DefaultJSONEncoder +from httptoolkit.request import Request +from httptoolkit.service import Service +from httptoolkit.transport import HttpxTransport +from httptoolkit.sent_request import SentRequest +from httptoolkit import HttpMethod + +if TYPE_CHECKING: + from pytest import FixtureRequest as _FixtureRequest + + T = TypeVar("T") + + class FixtureRequest(_FixtureRequest, Generic[T]): + param: T + + +class DummyService(Service): + pass + + +class CustomJSONEncoder(DefaultJSONEncoder): + def default(self, obj: Any) -> Any: + if isinstance(obj, decimal.Decimal): + return str(obj) + if isinstance(obj, datetime.date): + return obj.strftime("%m/%d/%Y") + return super().default(obj) + + +@pytest.fixture +def get_request() -> Request: + return Request( + method=HttpMethod.GET, + path="/put/some/data/here", + headers=( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + ), + params={"please": True, "carefully": True}, + body=None, + ) + + +@pytest.fixture +def get_sent_request(get_request) -> SentRequest: + return SentRequest( + request=get_request, + base_url="https://example.com:4321", + headers=( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + ), + body=None, + proxies={"http://": "http://10.10.1.10:3128"}, + ) + + +@pytest.fixture +def post_request() -> Request: + return Request( + method=HttpMethod.POST, + path="/put/some/data/here", + headers=( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + ), + params={"please": True, "carefully": True}, + body="It always seems impossible until it's done.", + ) + + +@pytest.fixture +def post_sent_request(post_request) -> SentRequest: + return SentRequest( + request=post_request, + base_url="https://example.com:4321", + headers=( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + ), + body="It always seems impossible until it's done.", + proxies={"http://": "http://10.10.1.10:3128"}, + ) + + +@pytest.fixture(params=[HttpMethod.POST, HttpMethod.PATCH, HttpMethod.PUT, HttpMethod.DELETE]) +def x_request_dict_json(request: "FixtureRequest[HttpMethod]") -> Tuple[str, Request]: + return request.param.value, Request( + method=request.param, + path="/put/some/data/here", + headers=( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + ), + params={"please": True, "carefully": True}, + json={"param1": 1, "param2": 2}, + ) + + +@pytest.fixture +def x_sent_request_dict_json(x_request_dict_json) -> Tuple[str, SentRequest]: + return x_request_dict_json[0], SentRequest( + request=x_request_dict_json[1], + base_url="https://example.com:4321", + headers=( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + Header(name="Content-Type", value="application/json", is_sensitive=False), + ), + body=b'{"param1": 1, "param2": 2}', + proxies={"http://": "http://10.10.1.10:3128"}, + ) + + +@pytest.fixture(params=[HttpMethod.POST, HttpMethod.PATCH, HttpMethod.PUT, HttpMethod.DELETE]) +def x_request_list_json(request: "FixtureRequest[HttpMethod]") -> Tuple[str, Request]: + return request.param.value, Request( + method=request.param, + path="/put/some/data/here", + headers=( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + ), + params={"please": True, "carefully": True}, + json=[ + {"param1": 1, "param2": 2}, + {"param3": 3, "param4": 4}, + ], + ) + + +@pytest.fixture(params=[HttpMethod.POST, HttpMethod.PATCH, HttpMethod.PUT, HttpMethod.DELETE]) +def x_custom_request_json(request: "FixtureRequest[HttpMethod]") -> Tuple[str, Request]: + return request.param.value, Request( + method=request.param, + path="/put/some/data/here", + headers=( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + ), + params={"please": True, "carefully": True}, + json={ + "param1": 1, + "param2": 2, + "time": date(2023, 7, 17), + "decimal": Decimal("0.5656"), + }, + ) + + +@pytest.fixture(params=[HttpMethod.POST, HttpMethod.PATCH, HttpMethod.PUT, HttpMethod.DELETE]) +def x_request_file(request: "FixtureRequest[HttpMethod]", test_file: BinaryIO) -> Tuple[str, Request]: + return request.param.value, Request( + method=request.param, + path="/put/some/data/here", + headers=( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + Header(name="Content-Type", value="multipart/form-data; boundary=secretboundary", is_sensitive=False), + ), + params={"please": True, "carefully": True}, + body="Stallone is a Woman", + files={"upload-file": test_file}, + ) + + +@pytest.fixture +def patch_request() -> Request: + return Request( + method=HttpMethod.PATCH, + path="/put/some/data/here", + headers=( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + ), + params={"please": True, "carefully": True}, + body="It always seems impossible until it's done.", + ) + + +@pytest.fixture +def service() -> DummyService: + return DummyService( + headers=(Header(name="ServiceHeader", value="service-header", is_sensitive=False),), + transport=HttpxTransport(base_url="https://example.com:4321", proxies={"http://": "http://10.10.1.10:3128"}), + ) + + +@pytest.fixture +def custom_json_encoder() -> Type[CustomJSONEncoder]: + return CustomJSONEncoder + + +@pytest.fixture +def filtered_and_masked_headers() -> Set[Header]: + return { + Header( + name="VeryClassBasedHeaderSens", + value="idkfa", + is_sensitive=True, + create_mask=lambda value: f"...{value[::-1]}", + ), + Header(name="ClassBasedHeader", value="idkfa", is_sensitive=False, create_mask=None), + Header(name="ClassBasedHeaderSens", value="idkfa", is_sensitive=True, create_mask=None), + Header(name="Authorization", value="idkfa", is_sensitive=True, create_mask=None), + } + + +@pytest.fixture +def test_file(): + file_path = os.path.join(os.path.dirname(__file__), "fixtures", "test.csv") + return open(file_path, "rb") diff --git a/tests/httptoolkit/fixtures/__init__.py b/tests/httptoolkit/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/httptoolkit/fixtures/test.csv b/tests/httptoolkit/fixtures/test.csv new file mode 100644 index 0000000..caaac48 --- /dev/null +++ b/tests/httptoolkit/fixtures/test.csv @@ -0,0 +1 @@ +Hi, world! \ No newline at end of file diff --git a/tests/httptoolkit/response/__init__.py b/tests/httptoolkit/response/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/httptoolkit/response/test_async_stream_error_response.py b/tests/httptoolkit/response/test_async_stream_error_response.py new file mode 100644 index 0000000..a950c28 --- /dev/null +++ b/tests/httptoolkit/response/test_async_stream_error_response.py @@ -0,0 +1,39 @@ +import pytest +from pytest_httpx import HTTPXMock + +from httptoolkit import AsyncHttpxService +from httptoolkit.errors import ServiceError + + +class MyAsyncService(AsyncHttpxService): + def __init__(self, key, *args, **kwargs): + self.key = key + super(MyAsyncService, self).__init__(url="https://example.com:4321/", *args, **kwargs) + + +@pytest.fixture +def async_stream_service(): + return MyAsyncService(key="fakecode") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ("get", "post", "put", "patch")) +async def test_async_stream_response_is_not_successful( + method: str, + async_stream_service, + httpx_mock: HTTPXMock, +): + httpx_mock.add_response( + method=method.upper(), + url="https://example.com:4321/api3/stat?key=fakecode", + text="Param 'key' not specified or invalid", + status_code=400, + ) + + async_service_method = getattr(async_stream_service, f"{method}_stream") + + with pytest.raises(ServiceError) as error: + async with async_service_method("api3/stat", params={"key": async_stream_service.key}): + pass + + assert "Param 'key' not specified or invalid" in str(error.value) diff --git a/tests/httptoolkit/response/test_response.py b/tests/httptoolkit/response/test_response.py new file mode 100644 index 0000000..6a9179c --- /dev/null +++ b/tests/httptoolkit/response/test_response.py @@ -0,0 +1,55 @@ +from datetime import timedelta +from unittest.mock import Mock + +import pytest +from httpx import Response as OriginalResponse + +from httptoolkit.response import Response + + +@pytest.fixture +def response(): + original_response = Mock(spec=OriginalResponse) + + original_response.ok = True + original_response.status_code = 200 + original_response.reason_phrase = "OK" + original_response.headers = {"ResponseHeader": "response-header"} + original_response.content = b"Schwarzenegger is a woman!" + original_response.text = "Schwarzenegger is a woman!" + original_response.json.return_value = {"Foo": "bar"} + original_response.elapsed = timedelta(182) + + return Response(original_response) + + +def test_ok(response): + assert response.ok + + +def test_status_code(response): + assert response.status_code == 200 + + +def test_request_time(response): + assert response.elapsed == timedelta(182) + + +def test_reason(response): + assert response.reason == "OK" + + +def test_headers(response): + assert response.headers == {"ResponseHeader": "response-header"} + + +def test_content(response): + assert response.content == b"Schwarzenegger is a woman!" + + +def test_text(response): + assert response.text == "Schwarzenegger is a woman!" + + +def test_json(response): + assert response.json() == {"Foo": "bar"} diff --git a/tests/httptoolkit/response/test_stream_error_response.py b/tests/httptoolkit/response/test_stream_error_response.py new file mode 100644 index 0000000..093b49b --- /dev/null +++ b/tests/httptoolkit/response/test_stream_error_response.py @@ -0,0 +1,38 @@ +import pytest +from pytest_httpx import HTTPXMock + +from httptoolkit import HttpxService +from httptoolkit.errors import ServiceError + + +class MyService(HttpxService): + def __init__(self, key, *args, **kwargs): + self.key = key + super(MyService, self).__init__(url="https://example.com:4321/", *args, **kwargs) + + +@pytest.fixture +def stream_service(): + return MyService(key="fakecode") + + +@pytest.mark.parametrize("method", ("get", "post", "put", "patch")) +def test_stream_response_is_not_successful( + method: str, + stream_service, + httpx_mock: HTTPXMock, +): + httpx_mock.add_response( + method=method.upper(), + url="https://example.com:4321/api3/stat?key=fakecode", + text="Param 'key' not specified or invalid", + status_code=400, + ) + + service_method = getattr(stream_service, f"{method}_stream") + + with pytest.raises(ServiceError) as error: + with service_method("api3/stat", params={"key": stream_service.key}): + pass + + assert "Param 'key' not specified or invalid" in str(error.value) diff --git a/tests/httptoolkit/service/__init__.py b/tests/httptoolkit/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/httptoolkit/service/test_async_service.py b/tests/httptoolkit/service/test_async_service.py new file mode 100644 index 0000000..9693d3b --- /dev/null +++ b/tests/httptoolkit/service/test_async_service.py @@ -0,0 +1,242 @@ +import json +from datetime import timedelta + +import httpx +import pytest +from pytest_httpx import HTTPXMock +from typing import Any + +from httptoolkit import Header, AsyncHttpxService +from httptoolkit.errors import HttpError, ServiceError +from httptoolkit.service import AsyncService +from httptoolkit.transport import AsyncHttpxTransport +from tests.httptoolkit.conftest import CustomJSONEncoder + + +class DummyAsyncService(AsyncService): + pass + + +@pytest.fixture() +def async_service() -> AsyncService: + return DummyAsyncService( + transport=AsyncHttpxTransport(base_url="https://example.com", proxies={"http://": "http://10.10.1.10:3128"}), + headers=(Header(name="ServiceHeader", value="async_service-header", is_sensitive=False),), + ) + + +@pytest.fixture +def async_service_with_custom_json_encoder() -> AsyncService: + return AsyncService( + transport=AsyncHttpxTransport( + base_url="https://example.com:4321", + json_encoder=lambda content: json.dumps(content, cls=CustomJSONEncoder), + ), + ) + + +@pytest.fixture +def async_service_with_only_url() -> AsyncHttpxService: + return AsyncHttpxService("https://example.com:4321") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ("get", "post", "put", "delete", "patch")) +async def test_requests_when_successful( + method: str, + async_service: AsyncService, + httpx_mock: HTTPXMock, +): + response_text = "Schwarzenegger is a woman!" + path = "/some/path" + + httpx_mock.add_response( + method=method.upper(), + url="https://example.com" + path, + text=response_text, + ) + + service_method = getattr(async_service, method) + + response = await service_method( + path, + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + ) + + assert response.status_code == 200 + assert response.text == response_text + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].headers["ServiceHeader"] == "async_service-header" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ("get", "post", "put", "delete", "patch")) +async def test_stream_requests_when_successful( + method: str, + async_service: AsyncService, + httpx_mock: HTTPXMock, +): + response_text = "Schwarzenegger is a woman!" + path = "/some/path" + + httpx_mock.add_response( + method=method.upper(), + url="https://example.com" + path, + text=response_text, + ) + + service_method = getattr(async_service, f"{method}_stream") + + async with service_method( + path, + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + ) as async_stream_response: + assert async_stream_response.status_code == 200 + assert response_text == (await async_stream_response.read()).decode("utf8") + + calls = httpx_mock.get_requests() + + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].headers["ServiceHeader"] == "async_service-header" + + +@pytest.mark.asyncio +async def test_post_when_request_successful(async_service: AsyncService, httpx_mock: HTTPXMock): + response_text = "Schwarzenegger is a woman!" + path = "/some/path" + + httpx_mock.add_response( + method="POST", + url="https://example.com" + path + "?foo=bar", + text=response_text, + ) + + response = await async_service.post( + path, + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + json={"param1": 1, "param2": 2}, + ) + + assert response.status_code == 200 + assert response.elapsed > timedelta(0) + assert response.text == response_text + + calls = httpx_mock.get_requests() + + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].headers["ServiceHeader"] == "async_service-header" + assert calls[0].headers["Content-Type"] == "application/json" + assert calls[0].content == b'{"param1": 1, "param2": 2}' + + +@pytest.mark.asyncio +async def test_if_response_not_successful(async_service: AsyncService, httpx_mock: HTTPXMock): + response_text = "Authorize first" + path = "/some/path" + + httpx_mock.add_response( + method="GET", + url="https://example.com" + path + "?foo=bar", + text=response_text, + status_code=401, + ) + + with pytest.raises(HttpError) as error: + await async_service.get( + path, + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + ) + + assert "Authorize first" in str(error.value) + assert error.value.response_code() == 401 + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "async_service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + + +@pytest.mark.asyncio +async def test_post_when_transport_error(async_service: AsyncService, httpx_mock: HTTPXMock): + path = "/some/path" + + httpx_mock.add_exception( + method="POST", + url="https://example.com" + path + "?foo=bar", + exception=httpx.TimeoutException("Timeout reached"), + ) + + with pytest.raises(ServiceError) as error: + await async_service.post( + path, + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + body="body", + ) + + assert "TimeoutException: Timeout reached" in str(error.value) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "async_service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"body" + + +def test_headers(async_service_with_only_url): + assert async_service_with_only_url.headers == () + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ("post", "put", "delete", "patch")) +async def test_json_request_with_falsy_values( + async_service: AsyncService, + httpx_mock: HTTPXMock, + method: str, +) -> None: + httpx_mock.add_response( + method=method.upper(), + url="https://example.com/", + text="OK", + ) + + falsy_values: Any = ({}, [], (), 0, 0.0, "", False) + + for json_value in falsy_values: + response = await getattr(async_service, method)(path="/", json=json_value) + assert response.status_code == 200 + assert response.elapsed > timedelta(0) + assert response.text == "OK" + + for request in httpx_mock.get_requests(): + assert request.headers["Content-Type"] == "application/json" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ("post", "put", "delete", "patch")) +async def test_json_request_with_none_value( + async_service: AsyncService, + httpx_mock: HTTPXMock, + method: str, +) -> None: + httpx_mock.add_response( + method=method.upper(), + url="https://example.com/", + text="OK", + ) + + response = await getattr(async_service, method)(path="/", json=None) + + assert response.status_code == 200 + assert response.text == "OK" + assert response.elapsed > timedelta(0) + + request = httpx_mock.get_request() + + assert "Content-Type" not in request.headers diff --git a/tests/httptoolkit/service/test_service.py b/tests/httptoolkit/service/test_service.py new file mode 100644 index 0000000..f60ba47 --- /dev/null +++ b/tests/httptoolkit/service/test_service.py @@ -0,0 +1,385 @@ +import json +from datetime import timedelta + +import httpx +import pytest +from pytest_httpx import HTTPXMock +from typing import Any + +from httptoolkit import Header, HttpxService +from httptoolkit.errors import HttpError, ServiceError +from httptoolkit.service import Service +from httptoolkit.transport import HttpxTransport +from tests.httptoolkit.conftest import CustomJSONEncoder + + +@pytest.fixture +def service_with_only_url() -> HttpxService: + return HttpxService( + url="https://example.com:4321", + ) + + +@pytest.fixture +def service_with_custom_json_encoder() -> Service: + return Service( + transport=HttpxTransport( + base_url="https://example.com:4321", + json_encoder=lambda content: json.dumps(content, cls=CustomJSONEncoder), + ), + ) + + +@pytest.mark.parametrize("method", ("get", "post", "put", "delete", "patch")) +def test_stream_requests_when_successful( + method: str, + service: Service, + httpx_mock: HTTPXMock, +): + response_text = "Schwarzenegger is a woman!" + path = "/some/path" + + httpx_mock.add_response( + method=method.upper(), + url="https://example.com:4321" + path, + text=response_text, + ) + + service_method = getattr(service, f"{method}_stream") + + with service_method( + path, headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),) + ) as stream_response: + assert stream_response.status_code == 200 + assert response_text == stream_response.read().decode("utf8") + + calls = httpx_mock.get_requests() + + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].headers["ServiceHeader"] == "service-header" + + +def test_post_when_response_is_successful(service, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="POST", + url="https://example.com:4321/foo?foo=bar", + text="Schwarzenegger is a woman!", + ) + + response = service.post( + "/foo", + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + body="body", + ) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"body" + + +def test_post_when_response_is_not_successful(service, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="POST", + url="https://example.com:4321/foo?foo=bar", + text="Authorize first", + status_code=401, + ) + + with pytest.raises(HttpError) as error: + service.post( + "/foo", + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + body="body", + ) + + assert "Authorize first" in str(error.value) + assert error.value.response_code() == 401 + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"body" + + +def test_post_when_transport_error(service, httpx_mock: HTTPXMock): + httpx_mock.add_exception( + method="POST", + url="https://example.com:4321/foo?foo=bar", + exception=httpx.TimeoutException("Timeout reached"), + ) + + with pytest.raises(ServiceError) as error: + service.post( + "/foo", + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + body="body", + ) + + assert "TimeoutException: Timeout reached" in str(error.value) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"body" + + +def test_patch_when_response_is_successful(service, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="PATCH", + url="https://example.com:4321/foo?foo=bar", + text="Schwarzenegger is a woman!", + ) + + response = service.patch( + "/foo", + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + body="body", + ) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"body" + + +def test_patch_when_response_is_not_successful(service, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="PATCH", + url="https://example.com:4321/foo?foo=bar", + text="Authorize first", + status_code=401, + ) + + with pytest.raises(HttpError) as error: + service.patch( + "/foo", + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + body="body", + ) + + assert "Authorize first" in str(error.value) + assert error.value.response_code() == 401 + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"body" + + +def test_patch_when_transport_error(service, httpx_mock: HTTPXMock): + httpx_mock.add_exception( + method="PATCH", + url="https://example.com:4321/foo?foo=bar", + exception=httpx.TimeoutException("Timeout reached"), + ) + + with pytest.raises(ServiceError) as error: + service.patch( + "/foo", + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + body="body", + ) + + assert "TimeoutException: Timeout reached" in str(error.value) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"body" + + +def test_get_when_response_is_successful(service, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url="https://example.com:4321/foo?foo=bar", + text="Schwarzenegger is a woman!", + ) + + response = service.get( + "/foo", + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + ) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + + +def test_get_with_headers_tuple_when_response_is_successful(service, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url="https://example.com:4321/foo?foo=bar", + text="Schwarzenegger is a woman!", + ) + + response = service.get( + "/foo", + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + ) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + + +def test_get_when_response_is_not_successful(service, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url="https://example.com:4321/foo", + text="Authorize first", + status_code=401, + ) + + with pytest.raises(HttpError) as error: + service.get("/foo") + + assert "Authorize first" in str(error.value) + assert error.value.response_code() == 401 + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + + +def test_get_when_transport_error(service, httpx_mock: HTTPXMock): + httpx_mock.add_exception( + method="GET", + url="https://example.com:4321/foo", + exception=httpx.TimeoutException("Timeout reached"), + ) + + with pytest.raises(ServiceError) as error: + service.get("/foo") + + assert "TimeoutException: Timeout reached" in str(error.value) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + + +def test_put(service, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="PUT", + url="https://example.com:4321/foo?foo=bar", + text="Schwarzenegger is a woman!", + ) + + response = service.put( + "/foo", + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + body="body", + ) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"body" + + +def test_delete(service, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="DELETE", + url="https://example.com:4321/foo?foo=bar", + text="Schwarzenegger is a woman!", + ) + + response = service.delete( + "/foo", + headers=(Header(name="RequestHeader", value="request-header", is_sensitive=False),), + params={"foo": "bar"}, + body="body", + ) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"body" + + +def test_headers(service_with_only_url): + assert service_with_only_url.headers == tuple() + + +@pytest.mark.parametrize("method", ("post", "put", "delete", "patch")) +def test_json_request_with_falsy_values( + service: Service, + httpx_mock: HTTPXMock, + method: str, +) -> None: + httpx_mock.add_response( + method=method.upper(), + url="https://example.com:4321/", + text="OK", + ) + + falsy_values: Any = ({}, [], (), 0, 0.0, "", False) + + for json_value in falsy_values: + response = getattr(service, method)(path="/", json=json_value) + assert response.status_code == 200 + assert response.text == "OK" + + for request in httpx_mock.get_requests(): + assert request.headers["Content-Type"] == "application/json" + + +@pytest.mark.parametrize("method", ("post", "put", "delete", "patch")) +def test_json_request_with_none_value( + service: Service, + httpx_mock: HTTPXMock, + method: str, +) -> None: + httpx_mock.add_response( + method=method.upper(), + url="https://example.com:4321/", + text="OK", + ) + + response = getattr(service, method)(path="/", json=None) + + assert response.status_code == 200 + assert response.text == "OK" + assert response.elapsed > timedelta(0) + + request = httpx_mock.get_request() + + assert "Content-Type" not in request.headers diff --git a/tests/httptoolkit/service/test_service_error.py b/tests/httptoolkit/service/test_service_error.py new file mode 100644 index 0000000..7b7090e --- /dev/null +++ b/tests/httptoolkit/service/test_service_error.py @@ -0,0 +1,93 @@ +from unittest.mock import Mock + +import pytest + +from httptoolkit.errors import ServiceError + + +def test_str_when_response_is_not_set(get_sent_request): + error = ServiceError(get_sent_request) + + assert str(error) == "\n".join( + [ + "Request: GET https://example.com:4321/put/some/data/here?please=True&carefully=True", + "Request headers: {'ServiceHeader': 'service-header', 'RequestHeader': 'request-header'}", + "Proxies: {'http://': 'http://10.10.1.10:3128'}", + ] + ) + + +def test_str_when_response_is_set(get_sent_request): + response = Mock() + response.status_code = 401 + response.reason = "Unauthorized" + response.text = "Invalid secret token" + response.headers = {"go": "Away"} + + error = ServiceError(get_sent_request, response) + + assert str(error) == "\n".join( + [ + "Request: GET https://example.com:4321/put/some/data/here?please=True&carefully=True", + "Request headers: {'ServiceHeader': 'service-header', 'RequestHeader': 'request-header'}", + "Proxies: {'http://': 'http://10.10.1.10:3128'}", + "Response: 401 Unauthorized", + "Response headers: {'go': 'Away'}", + "Response body: Invalid secret token", + ] + ) + + +def test_str_when_there_is_cause(get_sent_request): + with pytest.raises(ServiceError) as error: + try: + raise Exception("original error message") + except Exception as exc: + raise ServiceError(get_sent_request) from exc + + assert str(error.value) == "\n".join( + [ + "original error message", + "Request: GET https://example.com:4321/put/some/data/here?please=True&carefully=True", + "Request headers: {'ServiceHeader': 'service-header', 'RequestHeader': 'request-header'}", + "Proxies: {'http://': 'http://10.10.1.10:3128'}", + ] + ) + + +def test_response_code_when_response_is_set(get_sent_request): + response = Mock() + response.status_code = 401 + + error = ServiceError(get_sent_request, response) + + assert error.response_code() == 401 + + +def test_response_code_when_response_is_not_set(get_sent_request): + error = ServiceError(get_sent_request) + assert error.response_code() is None + + +def test_response_body_when_response_is_set(get_sent_request): + response = Mock() + response.text = "gladiolus" + + error = ServiceError(get_sent_request, response) + + assert error.response_body() == "gladiolus" + + +def test_response_body_when_response_is_not_set(get_sent_request): + error = ServiceError(get_sent_request) + assert error.response_body() is None + + +def test_error_context_if_no_cause(get_sent_request): + with pytest.raises(ServiceError) as error: + try: + raise ConnectionError("no connection") + except Exception: + raise ServiceError(get_sent_request) + + assert "no connection" in str(error.value) diff --git a/tests/httptoolkit/service/test_service_error_typecast.py b/tests/httptoolkit/service/test_service_error_typecast.py new file mode 100644 index 0000000..abd184e --- /dev/null +++ b/tests/httptoolkit/service/test_service_error_typecast.py @@ -0,0 +1,26 @@ +from unittest.mock import Mock + +from httptoolkit.errors import HttpError, HttpErrorTypecast + + +def test_is_bad_request_error_when_not_service_error(): + error = Exception() + assert HttpErrorTypecast.is_bad_request_error(error) is False + + +def test_is_bad_request_error_when_not_bad_request_error(get_request): + response = Mock() + response.status_code = 409 + + error = HttpError(get_request, response) + + assert HttpErrorTypecast.is_bad_request_error(error) is False + + +def test_is_bad_request_error_when_bad_request_error(get_request): + response = Mock() + response.status_code = 400 + + error = HttpError(get_request, response) + + assert HttpErrorTypecast.is_bad_request_error(error) is True diff --git a/tests/httptoolkit/test_error.py b/tests/httptoolkit/test_error.py new file mode 100644 index 0000000..cb64c42 --- /dev/null +++ b/tests/httptoolkit/test_error.py @@ -0,0 +1,98 @@ +import pytest +from pytest_httpx import HTTPXMock + +from httptoolkit import Header, suppress_http_error, HttpMethod +from httptoolkit.errors import Error, ServiceError +from httptoolkit.request import Request +from httptoolkit.sent_request import SentRequest + + +def test_str_when_no_cause(): + error = Error("message") + assert str(error) == "message" + + +def test_str_when_there_is_cause(): + with pytest.raises(Error) as error: + try: + raise RuntimeError("inner") + except RuntimeError as exc: + raise Error("outer") from exc + + assert str(error.value) == "RuntimeError: inner" + + +def test_str_when_there_is_context(): + with pytest.raises(Error) as error: + try: + raise RuntimeError("inner") + except RuntimeError: + raise Error("outer") + + assert str(error.value) == "RuntimeError: inner" + + +class TestServiceError: + def test_dumps_filtered_and_masked_request_headers(self): + sent_request = SentRequest( + Request( + method=HttpMethod.GET, + path="/", + headers=(), + params={}, + ), + headers=( + Header(name="Authorization", value="auth.sid 123", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + Header( + name="MaskedRequestHeader", + value="masked-request-header", + is_sensitive=True, + create_mask=lambda value: f"...{value[-4:]}", + ), + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + ), + base_url="https://example.com:4321", + body=None, + ) + + error = ServiceError(sent_request) + + print(error) + + assert "'Authorization': '[filtered]'" in str(error) + assert "'Authorization': 'auth.sid 123'" not in str(error) + assert "'RequestHeader': 'request-header'" in str(error) + assert "'MaskedRequestHeader': '...ader'" in str(error) + + +def test_suppress_http_error(service, httpx_mock: HTTPXMock): + httpx_mock.add_response( + method="GET", + url="https://example.com:4321" + "/", + status_code=404, + ) + + @suppress_http_error(404) + def meth(): + service.get("/") + + def meth2(): + service.get("/") + + @suppress_http_error(400) + def meth3(): + service.get("/") + + result = meth() + assert result is None + + with pytest.raises(Error): + meth2() + + with suppress_http_error(404): + result = meth2() + assert result is None + + with pytest.raises(Error): + meth3() diff --git a/tests/httptoolkit/test_error_pickle.py b/tests/httptoolkit/test_error_pickle.py new file mode 100644 index 0000000..d27d859 --- /dev/null +++ b/tests/httptoolkit/test_error_pickle.py @@ -0,0 +1,114 @@ +import pickle + +from httptoolkit import HttpxService, HttpMethod +import pytest +from pytest_httpx import HTTPXMock +from httptoolkit.errors import ServiceError +from httptoolkit.request import Request +from httptoolkit.sent_request import SentRequest + + +class TestChaining: + class Error(Exception): + def __str__(self): + return f"{self.__context__}\n{super().__str__()}" if self.__context__ else super().__str__() + + @pytest.fixture() + def sent_request_object(self): + return SentRequest( + Request(method=HttpMethod.GET, path="/", params={}), base_url="base-url", proxies={}, body=None + ) + + def test_collects_context(self, sent_request_object): + with pytest.raises(ServiceError) as service_error: + try: + raise self.Error("First") + except Exception: + try: + raise self.Error("Second") + except Exception: + raise ServiceError(sent_request_object) + + pickled_error = str(pickle.loads(pickle.dumps(service_error.value))) + assert "Second" in pickled_error + assert "First" in pickled_error + + def test_collects_cause(self, sent_request_object): + chain = None + try: + raise self.Error("First") + except Exception as e: + chain = e + try: + raise self.Error("Second") from chain + except Exception as e: + chain = e + + with pytest.raises(ServiceError) as service_error: + raise ServiceError(sent_request_object) from chain + + pickled_error = str(pickle.loads(pickle.dumps(service_error.value))) + assert "Second" in pickled_error + assert "First" in pickled_error + + +class MyService(HttpxService): + def __init__(self, key, *args, **kwargs): + self.key = key + super(MyService, self).__init__(url="https://example.com:4321/", *args, **kwargs) + + +@pytest.fixture +def pickle_service(): + return MyService(key="fakecode") + + +@pytest.mark.parametrize("method", ("get", "post", "put", "patch")) +def test_stream_response_pickle_error( + method: str, + pickle_service, + httpx_mock: HTTPXMock, +): + httpx_mock.add_response( + method=method.upper(), + url="https://example.com:4321/api3/stat?key=fakecode", + text="Param 'key' not specified or invalid", + status_code=400, + ) + + service_method = getattr(pickle_service, f"{method}_stream") + + with pytest.raises(ServiceError) as error: + with service_method("api3/stat", params={"key": pickle_service.key}): + pass + + file = pickle.dumps(error.value) + pickled_error = str(pickle.loads(file)) + assert f"HttpError: Request: {method.upper()} https://example.com:4321/api3/stat?key=fakecode" in pickled_error + assert "Response: 400 Bad Request" in pickled_error + assert "Response body: Param 'key' not specified or invalid" in pickled_error + + +@pytest.mark.parametrize("method", ("get", "post", "put", "patch")) +def test_response_pickle_error( + method: str, + pickle_service, + httpx_mock: HTTPXMock, +): + httpx_mock.add_response( + method=method.upper(), + url="https://example.com:4321/api3/stat?key=fakecode", + text="Param 'key' not specified or invalid", + status_code=400, + ) + + service_method = getattr(pickle_service, f"{method}") + + with pytest.raises(ServiceError) as error: + service_method("api3/stat", params={"key": pickle_service.key}) + + file = pickle.dumps(error.value) + pickled_error = str(pickle.loads(file)) + assert f"Request: {method.upper()} https://example.com:4321/api3/stat?key=fakecode" in pickled_error + assert "Response: 400 Bad Request" in pickled_error + assert "Response body: Param 'key' not specified or invalid" in pickled_error diff --git a/tests/httptoolkit/test_header.py b/tests/httptoolkit/test_header.py new file mode 100644 index 0000000..74a029a --- /dev/null +++ b/tests/httptoolkit/test_header.py @@ -0,0 +1,143 @@ +import pytest + +from httptoolkit import Header, header +from httptoolkit.header import KnownSensitiveHeaders + + +class TestKnownSensitiveHeaders: + def test_add(self): + assert KnownSensitiveHeaders().add("ß").add("ß").items == {"ss"} + + def test_update(self): + assert KnownSensitiveHeaders().update(["ß", "ß"]).update(["ß", "ß"]).items == {"ss"} + + def test_in(self): + assert "ß" in KnownSensitiveHeaders(["ß"]) + + +def test_auth_headers(): + auth_sid_header = header.AuthSidHeader("foo") + assert auth_sid_header.name == "Authorization" + assert auth_sid_header.value == "auth.sid foo" + assert auth_sid_header.is_sensitive is True + + basic_auth_header = header.BasicAuthHeader(username="auth@example.ru", password="password") + assert basic_auth_header.name == "Authorization" + assert basic_auth_header.value == "Basic YXV0aEBleGFtcGxlLnJ1OnBhc3N3b3Jk" + assert basic_auth_header.is_sensitive is True + + auth_sid_header = header.BearerAuthHeader("42") + assert auth_sid_header.name == "Authorization" + assert auth_sid_header.value == "Bearer 42" + assert auth_sid_header.is_sensitive is True + + +def test_string_representation_of_headers(): + headers = [ + Header(name="ClassBasedHeader", value="idkfa", is_sensitive=False), + Header(name="ClassBasedHeaderSens", value="idkfa", is_sensitive=True), + Header(name="ClassBasedReqHeader", value="idkfa", is_sensitive=False), + Header(name="ClassBasedReqHeaderSens", value="idkfa", is_sensitive=True), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="authorization", value="idkfa", is_sensitive=True), + Header(name="myPasswordHeader", value="idkfa", is_sensitive=True), + Header(name="topSecretHeader", value="idkfa", is_sensitive=True), + ] + + string_headers = list(map(str, headers)) + + assert string_headers == [ + "ClassBasedHeader: idkfa", + "ClassBasedHeaderSens: [filtered]", + "ClassBasedReqHeader: idkfa", + "ClassBasedReqHeaderSens: [filtered]", + "RequestHeader: request-header", + "ServiceHeader: service-header", + "authorization: [filtered]", + "myPasswordHeader: [filtered]", + "topSecretHeader: [filtered]", + ] + + +def test_masked_representation_of_headers(): + headers = [ + Header( + name="ClassBasedHeader", + value="idkfadasd", + is_sensitive=True, + create_mask=lambda value: value[1:3], + ), + Header( + name="ClassBasedHeaderSens", + value="idkfadasd", + is_sensitive=True, + create_mask=lambda value: value[-4:], + ), + Header( + name="ClassBasedReqHeader", + value="idkfadasd", + is_sensitive=True, + create_mask=lambda value: value[-4:], + ), + Header( + name="ClassBasedReqHeaderSens", + value="idkfadas", + is_sensitive=True, + create_mask=lambda value: value[:-3], + ), + Header( + name="RequestHeader", + value="request-header", + is_sensitive=True, + create_mask=lambda value: value[2:6], + ), + Header( + name="ServiceHeader", + value="service-header", + is_sensitive=True, + create_mask=lambda value: value[::2], + ), + Header( + name="authorization", value="idkfadsad", is_sensitive=True, create_mask=lambda value: f"...{value[::-1]}" + ), + Header( + name="myPasswordHeader", value="idkfadasd", is_sensitive=True, create_mask=lambda value: f"...{value[-4:]}" + ), + Header( + name="topSecretHeader", value="idkfadasd", is_sensitive=True, create_mask=lambda value: f"...{value[-4:]}" + ), + ] + + string_masked_headers = list(map(str, headers)) + assert string_masked_headers == [ + "ClassBasedHeader: dk", + "ClassBasedHeaderSens: dasd", + "ClassBasedReqHeader: dasd", + "ClassBasedReqHeaderSens: idkfa", + "RequestHeader: ques", + "ServiceHeader: sriehae", + "authorization: ...dasdafkdi", + "myPasswordHeader: ...dasd", + "topSecretHeader: ...dasd", + ] + + with pytest.raises(RuntimeError) as error: + Header(name="ServiceHeader", value="service-header", is_sensitive=False, create_mask=lambda value: value[::2]) + assert ( + str(error.value) + == "'.mask' may only be set if is_sensitive = True\nor name is in KNOWN_SENSITIVE_HEADERS_NAMES" + ) + + +def test_masked_representation_auth_headers(): + masked_auth_sid_header = header.AuthSidHeader("foooooo", create_mask=lambda value: value[4:12]) + assert str(masked_auth_sid_header) == "Authorization: .sid foo" + + masked_basic_auth_header = header.BasicAuthHeader( + username="auth@example.ru", password="password", create_mask=lambda value: value[1:3] + ) + assert str(masked_basic_auth_header) == "Authorization: as" + + masked_auth_sid_header = header.BearerAuthHeader("42", create_mask=lambda value: value[::-1]) + assert str(masked_auth_sid_header) == "Authorization: 24 reraeB" diff --git a/tests/httptoolkit/test_request.py b/tests/httptoolkit/test_request.py new file mode 100644 index 0000000..f3c8496 --- /dev/null +++ b/tests/httptoolkit/test_request.py @@ -0,0 +1,126 @@ +import pytest + +from httptoolkit import AuthSidHeader, BasicAuthHeader, BearerAuthHeader, Header, HttpMethod +from httptoolkit.request import Request + + +def test_method(post_request): + assert post_request.method == "POST" + + +def test_path(post_request): + assert post_request.path == "/put/some/data/here" + + +def test_params(post_request): + assert post_request.params == {"please": True, "carefully": True} + + +def test_headers(post_request): + assert set(post_request.headers) == { + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + } + + +def test_set_new_headers(test_file): + old_post_request = Request( + method=HttpMethod.POST, + path="/put/some/data/here", + headers=( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + ), + params={"please": True, "carefully": True}, + body="It always seems impossible until it's done.", + json={"param1": 1, "param2": 2}, + files={"upload-file": test_file}, + ) + new_post_request = old_post_request.set_new_headers( + ( + Header(name="NewHeader1", value="new-header", is_sensitive=False), + Header(name="NewHeader2", value="very-new-header", is_sensitive=False), + ) + ) + assert old_post_request.headers == ( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + ) + + assert new_post_request.method == "POST" + assert new_post_request.path == "/put/some/data/here" + assert new_post_request.headers == ( + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + Header(name="NewHeader1", value="new-header", is_sensitive=False), + Header(name="NewHeader2", value="very-new-header", is_sensitive=False), + ) + assert new_post_request.params == {"please": True, "carefully": True} + assert new_post_request.body == "It always seems impossible until it's done." + assert new_post_request.json == {"param1": 1, "param2": 2} + assert new_post_request.files == {"upload-file": test_file} + + +def test_filtered_and_masked_headers(filtered_and_masked_headers): + request = Request( + method=HttpMethod.GET, + path="/", + headers=tuple(filtered_and_masked_headers), + params={}, + ) + + assert set(request.headers) == filtered_and_masked_headers + + assert request.filtered_headers == { + "ClassBasedHeader": "idkfa", + "ClassBasedHeaderSens": "[filtered]", + "VeryClassBasedHeaderSens": "...afkdi", + "Authorization": "[filtered]", + } + + +@pytest.mark.parametrize( + "auth_header", + ( + AuthSidHeader("123"), + BasicAuthHeader(username="auth@example.ru", password="password"), + BearerAuthHeader(value="42"), + ), +) +def test_auth_headers(auth_header): + request = Request( + method=HttpMethod.GET, + path="/", + headers=( + auth_header, + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + ), + params={}, + ) + + assert request.filtered_headers == { + "Authorization": "[filtered]", + "ServiceHeader": "service-header", + } + + +def test_body(post_request): + assert post_request.body == "It always seems impossible until it's done." + + +def test_json(post_request): + assert post_request.json is None + + +def test_json_present(): + x_request = Request(HttpMethod.POST, "/", {}, json={"param1": 1, "param2": 2}) + assert x_request.json == {"param1": 1, "param2": 2} + + +def test_files(post_request): + assert post_request.files is None + + +def test_files_present(test_file): + request = Request(HttpMethod.POST, "/", {}, files={"upload-file": test_file}) + assert request.files == {"upload-file": test_file} diff --git a/tests/httptoolkit/test_retry.py b/tests/httptoolkit/test_retry.py new file mode 100644 index 0000000..7205e43 --- /dev/null +++ b/tests/httptoolkit/test_retry.py @@ -0,0 +1,154 @@ +from datetime import datetime, timedelta +from email.utils import format_datetime + +import pytest +from httpx import Request, Response as OriginalResponse, ConnectError, ConnectTimeout, ReadTimeout + +from httptoolkit.retry import RetryManager + + +@pytest.fixture +def httpx_request() -> Request: + return Request(method="GET", url="https://example.com:4321/") + + +@pytest.fixture +def retry_manager() -> RetryManager: + return RetryManager( + max_attempts=5, + backoff_factor=0.01, + exceptions=(ConnectError, ConnectTimeout), + dont_retry_headers=("Another-Dont-Retry", "Dont-Retry"), + ) + + +@pytest.fixture +def retry_manager_with_more_exceptions() -> RetryManager: + return RetryManager( + max_attempts=5, + backoff_factor=0.01, + exceptions=(ConnectError, ConnectTimeout, ReadTimeout), + dont_retry_headers="Dont-Retry", + ) + + +def test_retries(httpx_request: Request, retry_manager: RetryManager): + retries = retry_manager.get_retries("GET") + + retry = next(retries) + response = OriginalResponse(request=httpx_request, status_code=413) + + with retry: + retry.process_response(response) + + assert retry.backoff == 0.01 + + retry = next(retries) + response = OriginalResponse(request=httpx_request, status_code=429, headers={"Retry-After": "1"}) + + with retry: + retry.process_response(response) + + assert retry.backoff == 1 + + retry = next(retries) + response = OriginalResponse( + request=httpx_request, + status_code=503, + headers={"Retry-After": format_datetime(datetime.utcnow() + timedelta(seconds=1))}, + ) + + with retry: + retry.process_response(response) + + assert retry.backoff < 1 + + retry = next(retries) + response = OriginalResponse(request=httpx_request, status_code=413, headers={"Retry-After": "Invalid-Date"}) + + with retry: + retry.process_response(response) + + assert retry.backoff == 0.08 + + retry = next(retries) + response = OriginalResponse(request=httpx_request, status_code=429, headers={"Retry-After": ""}) + + with retry: + retry.process_response(response) + + assert retry.backoff == 0.16 + + with pytest.raises(StopIteration): + next(retries) + + +@pytest.mark.parametrize( + "headers", + ( + {}, + {"Another-Dont-Retry": ""}, + {"Another-Dont-Retry": "False"}, + {"Dont-Retry": ""}, + {"Dont-Retry": "False"}, + ), +) +def test_headers_do_retry( + headers: dict, + httpx_request: Request, + retry_manager: RetryManager, +) -> None: + retries = retry_manager.get_retries("GET") + + response = OriginalResponse(request=httpx_request, status_code=503, headers=headers) + retry = next(retries) + + with pytest.raises(retry._RetryForResponseException) as e: + retry.process_response(response) + + assert str(e.value) == "Service Unavailable" + + +@pytest.mark.parametrize( + "headers", + ( + {"Another-Dont-Retry": "True"}, + {"Dont-Retry": "True"}, + ), +) +def test_headers_dont_retry( + headers: dict, + httpx_request: Request, + retry_manager: RetryManager, +) -> None: + retries = retry_manager.get_retries("GET") + + response = OriginalResponse(request=httpx_request, status_code=503, headers=headers) + retry = next(retries) + + retry.process_response(response) + + assert response.status_code == 503 + assert response.reason_phrase == "Service Unavailable" + + +def test_suppressed_exceptions(): + suppress_all = RetryManager( + max_attempts=2, + backoff_factor=1, + exceptions=[Exception], + dont_retry_headers="Dont-Retry", + ) + suppress_nothing = RetryManager( + max_attempts=2, + backoff_factor=1, + exceptions=(), + dont_retry_headers="Dont-Retry", + ) + + with next(suppress_all.get_retries("GET")): + raise Exception("Wasted") + + with pytest.raises(Exception): + with next(suppress_nothing.get_retries("GET")): + raise Exception("Wasted") diff --git a/tests/httptoolkit/test_sent_request.py b/tests/httptoolkit/test_sent_request.py new file mode 100644 index 0000000..2c34266 --- /dev/null +++ b/tests/httptoolkit/test_sent_request.py @@ -0,0 +1,73 @@ +import pytest + +from httptoolkit import Header, HttpMethod +from httptoolkit.request import Request +from httptoolkit.sent_request import SentRequest + + +def test_method(post_sent_request): + assert post_sent_request.method == "POST" + + +def test_headers(x_sent_request_dict_json): + metod, x_request = x_sent_request_dict_json + assert set(x_request.headers) == { + Header(name="ServiceHeader", value="service-header", is_sensitive=False), + Header(name="RequestHeader", value="request-header", is_sensitive=False), + Header(name="Content-Type", value="application/json", is_sensitive=False), + } + + +def test_filtered_and_masked_headers(filtered_and_masked_headers): + request = Request( + method=HttpMethod.GET, + path="/", + headers=(), + params={}, + ) + sent_request = SentRequest( + request=request, + base_url="", + body=b"", + headers=tuple(filtered_and_masked_headers), + ) + assert set(sent_request.headers) == filtered_and_masked_headers + + assert sent_request.filtered_headers == { + "ClassBasedHeader": "idkfa", + "ClassBasedHeaderSens": "[filtered]", + "VeryClassBasedHeaderSens": "...afkdi", + "Authorization": "[filtered]", + } + + +@pytest.mark.parametrize( + "base_url,path", + [ + ("https://example.com:4321/you", "put/some/data/here"), + ("https://example.com:4321/you", "/put/some/data/here"), + ("https://example.com:4321/you/", "put/some/data/here"), + ("https://example.com:4321/you/", "/put/some/data/here"), + ], +) +def test_url(base_url, path): + request = SentRequest( + Request( + method=HttpMethod.POST, + path=path, + headers=(), + params={}, + body=None, + ), + base_url=base_url, + proxies={}, + body=None, + ) + + assert request.url == "https://example.com:4321/you/put/some/data/here" + + +def test_proxies(post_sent_request): + assert post_sent_request.proxies == { + "http://": "http://10.10.1.10:3128", + } diff --git a/tests/httptoolkit/test_sent_request_log_record.py b/tests/httptoolkit/test_sent_request_log_record.py new file mode 100644 index 0000000..7b131cc --- /dev/null +++ b/tests/httptoolkit/test_sent_request_log_record.py @@ -0,0 +1,34 @@ +from httptoolkit.sent_request_log_record import RequestLogRecord + + +def test_get_request_format(get_sent_request): + assert ( + str(RequestLogRecord(get_sent_request)) == "Sending GET https://example.com:4321/put/some/data/here" + "?please=True&carefully=True" + ) + + +def test_post_request_format(post_sent_request): + assert ( + str(RequestLogRecord(post_sent_request)) == "Sending POST https://example.com:4321/put/some/data/here" + "?please=True&carefully=True (body: 43)" + ) + + +def test_args(post_sent_request): + assert RequestLogRecord(post_sent_request).args() == { + "method": "POST", + "url": "https://example.com:4321/put/some/data/here?please=True&carefully=True", + "headers": "ServiceHeader: service-header\nRequestHeader: request-header", + "body_size": 43, + } + + +def test_args_json(x_sent_request_dict_json): + metod, x_request = x_sent_request_dict_json + assert RequestLogRecord(x_request).args() == { + "method": metod, + "url": "https://example.com:4321/put/some/data/here?please=True&carefully=True", + "headers": "ServiceHeader: service-header\nRequestHeader: request-header\nContent-Type: application/json", + "body_size": 29, + } diff --git a/tests/httptoolkit/transport/__init__.py b/tests/httptoolkit/transport/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/httptoolkit/transport/httpx_transport/__init__.py b/tests/httptoolkit/transport/httpx_transport/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/httptoolkit/transport/httpx_transport/session/__init__.py b/tests/httptoolkit/transport/httpx_transport/session/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/httptoolkit/transport/httpx_transport/session/test_async_session.py b/tests/httptoolkit/transport/httpx_transport/session/test_async_session.py new file mode 100644 index 0000000..ce5f5d5 --- /dev/null +++ b/tests/httptoolkit/transport/httpx_transport/session/test_async_session.py @@ -0,0 +1,120 @@ +from datetime import timedelta +from typing import Iterable, Iterator, Type + +import httpx +import pytest +from httpx import ConnectError, ConnectTimeout +from pytest_httpx import HTTPXMock + +from httptoolkit.retry import RetryManager +from httptoolkit.transport._httpx._session._async import AsyncHttpxSession + + +def gen_responses( + status_codes: Iterable[int], + text: str, + headers: dict, +) -> Iterator[httpx.Response]: + for status_code in status_codes: + yield httpx.Response(status_code=status_code, text=text, headers=headers) + + +@pytest.mark.asyncio +async def test_fail_then_success(httpx_mock: HTTPXMock): + status_codes = tuple(RetryManager.DEFAULT_STATUS_CODES) + (200,) + retry_max_attempts = len(status_codes) + responses = gen_responses(status_codes, text="Schwarzenegger is a woman!", headers={"X-Custom-Header": "value"}) + + httpx_mock.add_callback( + method="GET", + url="https://example.com:4321/foo?foo=bar", + callback=lambda request: next(responses), + ) + + session = AsyncHttpxSession( + retry_manager=RetryManager( + max_attempts=retry_max_attempts, + backoff_factor=0.01, + exceptions=(ConnectError, ConnectTimeout), + dont_retry_headers="Dont-Retry", + ) + ) + response = await session.get("https://example.com:4321/foo", params={"foo": "bar"}) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + assert response.headers["X-Custom-Header"] == "value" + + calls = httpx_mock.get_requests() + + assert len(calls) == retry_max_attempts + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "exc,exc_msg", + [ + (httpx.ConnectTimeout, "Your time is over"), + (httpx.ConnectError, "Smth goes wrong"), + ], +) +async def test_retry_after_exception( + httpx_mock: HTTPXMock, + exc: Type[Exception], + exc_msg: str, +): + retry_max_attempts = 3 + + httpx_mock.add_exception( + method="GET", + url="https://example.com:4321/foo?foo=bar", + exception=exc(exc_msg), + ) + + session = AsyncHttpxSession( + retry_manager=RetryManager( + max_attempts=retry_max_attempts, + backoff_factor=0.01, + exceptions=(ConnectError, ConnectTimeout), + dont_retry_headers="Dont-Retry", + ) + ) + + with pytest.raises(exc) as error: + await session.get("https://example.com:4321/foo", params={"foo": "bar"}) + + assert str(error.value) == exc_msg + + calls = httpx_mock.get_requests() + + assert len(calls) == retry_max_attempts + + +@pytest.mark.asyncio +async def test_retry_only_allowed_methods(httpx_mock: HTTPXMock): + exc_msg = "Your time is over" + + httpx_mock.add_exception( + method="GET", + url="https://example.com:4321/foo?foo=bar", + exception=httpx.ConnectTimeout(exc_msg), + ) + + session = AsyncHttpxSession( + retry_manager=RetryManager( + max_attempts=3, + backoff_factor=0.01, + methods=["POST"], + exceptions=(ConnectError, ConnectTimeout), + dont_retry_headers="Dont-Retry", + ) + ) + + with pytest.raises(httpx.ConnectTimeout) as error: + await session.get("https://example.com:4321/foo", params={"foo": "bar"}) + + assert str(error.value) == exc_msg + + calls = httpx_mock.get_requests() + + assert len(calls) == 1 diff --git a/tests/httptoolkit/transport/httpx_transport/session/test_async_stream_response.py b/tests/httptoolkit/transport/httpx_transport/session/test_async_stream_response.py new file mode 100644 index 0000000..438f82c --- /dev/null +++ b/tests/httptoolkit/transport/httpx_transport/session/test_async_stream_response.py @@ -0,0 +1,100 @@ +import pytest +from httpx import ByteStream, Request as OriginalRequest, ConnectError, ConnectTimeout +from pytest_httpx import HTTPXMock + +from httptoolkit.response import AsyncStreamResponse +from httptoolkit.retry import RetryManager +from httptoolkit.transport._httpx._session._async import AsyncHttpxSession + + +@pytest.fixture +def async_session(): + return AsyncHttpxSession( + retry_manager=RetryManager( + max_attempts=5, + backoff_factor=0.01, + exceptions=(ConnectError, ConnectTimeout), + dont_retry_headers="Dont-Retry", + ) + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ("GET", "POST", "PUT", "DELETE", "PATCH")) +async def test_stream_response( + method: str, + async_session: AsyncHttpxSession, + httpx_mock: HTTPXMock, +): + response_text = "first line\nsecond line\n" + url = "http://example.com/foo/bar" + + httpx_mock.add_response( + method=method.upper(), + url=url, + stream=ByteStream(response_text.encode("utf8")), + ) + + async with async_session.stream(OriginalRequest(method, url)) as httpx_response: + async_stream_response = AsyncStreamResponse(httpx_response) + assert async_stream_response.status_code == 200 + assert [chunk async for chunk in async_stream_response.iter_text(7)] == [ + "first l", + "ine\nsec", + "ond lin", + "e\n", + ] + + async with async_session.stream(OriginalRequest(method, url)) as httpx_response: + async_stream_response = AsyncStreamResponse(httpx_response) + assert async_stream_response.status_code == 200 + assert [chunk async for chunk in async_stream_response.iter_bytes(7)] == [ + b"first l", + b"ine\nsec", + b"ond lin", + b"e\n", + ] + + async with async_session.stream(OriginalRequest(method, url)) as httpx_response: + async_stream_response = AsyncStreamResponse(httpx_response) + assert async_stream_response.status_code == 200 + assert [chunk async for chunk in async_stream_response.iter_lines()] == ["first line", "second line"] + + async with async_session.stream(OriginalRequest(method, url)) as httpx_response: + async_stream_response = AsyncStreamResponse(httpx_response) + assert async_stream_response.status_code == 200 + assert [chunk async for chunk in async_stream_response.iter_raw(7)] == [ + b"first l", + b"ine\nsec", + b"ond lin", + b"e\n", + ] + + async with async_session.stream(OriginalRequest(method, url)) as httpx_response: + async_stream_response = AsyncStreamResponse(httpx_response) + assert async_stream_response.status_code == 200 + assert (await async_stream_response.read()).decode("utf8") == response_text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ("GET", "POST", "PUT", "DELETE", "PATCH")) +async def test_elapsed_not_available_until_async_response_closed( + method: str, + async_session: AsyncHttpxSession, + httpx_mock: HTTPXMock, +): + response_text = "first line\nsecond line\n" + url = "http://example.com/foo/bar" + + httpx_mock.add_response( + method=method.upper(), + url=url, + stream=ByteStream(response_text.encode("utf8")), + ) + + async with async_session.stream(OriginalRequest(method, url)) as httpx_response: + async_stream_response = AsyncStreamResponse(httpx_response) + assert async_stream_response.status_code == 200 + with pytest.raises(RuntimeError) as error: + async_stream_response.elapsed + assert str(error.value) == "'.elapsed' may only be accessed after the response has been read or closed." diff --git a/tests/httptoolkit/transport/httpx_transport/session/test_session.py b/tests/httptoolkit/transport/httpx_transport/session/test_session.py new file mode 100644 index 0000000..984982e --- /dev/null +++ b/tests/httptoolkit/transport/httpx_transport/session/test_session.py @@ -0,0 +1,147 @@ +import socket +from datetime import timedelta +from typing import Iterable, Iterator, Type + +import httpx +import pytest +from httpx import ConnectError, ConnectTimeout +from pytest_httpx import HTTPXMock + +from httptoolkit.retry import RetryManager +from httptoolkit.transport._httpx._session._sync import HttpxSession + + +def gen_responses( + status_codes: Iterable[int], + text: str, + headers: dict, +) -> Iterator[httpx.Response]: + for status_code in status_codes: + yield httpx.Response(status_code=status_code, text=text, headers=headers) + + +def test_fail_then_success(httpx_mock: HTTPXMock): + status_codes = tuple(RetryManager.DEFAULT_STATUS_CODES) + (200,) + retry_max_attempts = len(status_codes) + responses = gen_responses(status_codes, text="Schwarzenegger is a woman!", headers={"X-Custom-Header": "value"}) + + httpx_mock.add_callback( + method="GET", + url="https://example.com:4321/foo?foo=bar", + callback=lambda request: next(responses), + ) + + session = HttpxSession( + retry_manager=RetryManager( + max_attempts=retry_max_attempts, + backoff_factor=0.01, + exceptions=(ConnectError, ConnectTimeout), + dont_retry_headers="Dont-Retry", + ) + ) + response = session.get("https://example.com:4321/foo", params={"foo": "bar"}) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + assert response.headers["X-Custom-Header"] == "value" + + calls = httpx_mock.get_requests() + + assert len(calls) == retry_max_attempts + + +@pytest.mark.parametrize( + "exc,exc_msg", + [ + (httpx.ConnectTimeout, "Your time is over"), + (httpx.ConnectError, "Smth goes wrong"), + ], +) +def test_retry_after_exception( + httpx_mock: HTTPXMock, + exc: Type[Exception], + exc_msg: str, +): + retry_max_attempts = 3 + + httpx_mock.add_exception( + method="GET", + url="https://example.com:4321/foo?foo=bar", + exception=exc(exc_msg), + ) + + session = HttpxSession( + retry_manager=RetryManager( + max_attempts=retry_max_attempts, + backoff_factor=0.01, + exceptions=(ConnectError, ConnectTimeout), + dont_retry_headers="Dont-Retry", + ) + ) + + with pytest.raises(exc) as error: + session.get("https://example.com:4321/foo", params={"foo": "bar"}) + + assert str(error.value) == exc_msg + + calls = httpx_mock.get_requests() + + assert len(calls) == retry_max_attempts + + +def test_retry_behavior(): + original = socket.socket.connect + + calls = 0 + + def _patched(*args, **kwargs): + nonlocal calls + calls += 1 + return original(*args, **kwargs) + + socket.socket.connect = _patched + session = HttpxSession( + retry_manager=RetryManager( + max_attempts=2, + backoff_factor=0.01, + exceptions=(ConnectError, ConnectTimeout), + dont_retry_headers="Dont-Retry", + ) + ) + + try: + with pytest.raises(httpx.ConnectError): + session.get("http://0.0.0.0:9000") + finally: + socket.socket.connect = original + + assert calls == 2 + + +def test_retry_only_allowed_methods(httpx_mock: HTTPXMock): + exc_msg = "Your time is over" + + httpx_mock.add_exception( + method="GET", + url="https://example.com:4321/foo?foo=bar", + exception=httpx.ConnectTimeout(exc_msg), + ) + + session = HttpxSession( + retry_manager=RetryManager( + max_attempts=3, + backoff_factor=0.01, + methods=["POST"], + exceptions=(ConnectError, ConnectTimeout), + dont_retry_headers="Dont-Retry", + ) + ) + + with pytest.raises(httpx.ConnectTimeout) as error: + session.get("https://example.com:4321/foo", params={"foo": "bar"}) + + assert str(error.value) == exc_msg + + calls = httpx_mock.get_requests() + + assert len(calls) == 1 diff --git a/tests/httptoolkit/transport/httpx_transport/session/test_stream_response.py b/tests/httptoolkit/transport/httpx_transport/session/test_stream_response.py new file mode 100644 index 0000000..d69ec28 --- /dev/null +++ b/tests/httptoolkit/transport/httpx_transport/session/test_stream_response.py @@ -0,0 +1,90 @@ +from datetime import timedelta + +import pytest +from httpx import ByteStream, Request as OriginalRequest, ConnectError, ConnectTimeout +from pytest_httpx import HTTPXMock + +from httptoolkit.response import StreamResponse +from httptoolkit.retry import RetryManager +from httptoolkit.transport._httpx._session._sync import HttpxSession + + +@pytest.fixture +def session(): + return HttpxSession( + retry_manager=RetryManager( + max_attempts=5, + backoff_factor=0.01, + exceptions=(ConnectError, ConnectTimeout), + dont_retry_headers="Dont-Retry", + ) + ) + + +@pytest.mark.parametrize("method", ("GET", "POST", "PUT", "DELETE", "PATCH")) +def test_stream_response( + method: str, + session: HttpxSession, + httpx_mock: HTTPXMock, +): + response_text = "first line\nsecond line\n" + url = "http://example.com/foo/bar" + + httpx_mock.add_response( + method=method.upper(), + url=url, + stream=ByteStream(response_text.encode("utf8")), + ) + + with session.stream(OriginalRequest(method, url)) as httpx_response: + stream_response = StreamResponse(httpx_response) + assert stream_response.status_code == 200 + assert [chunk for chunk in stream_response.iter_text(7)] == ["first l", "ine\nsec", "ond lin", "e\n"] + assert stream_response.elapsed > timedelta(0) + + with session.stream(OriginalRequest(method, url)) as httpx_response: + stream_response = StreamResponse(httpx_response) + assert stream_response.status_code == 200 + assert [chunk for chunk in stream_response.iter_bytes(7)] == [b"first l", b"ine\nsec", b"ond lin", b"e\n"] + assert stream_response.elapsed > timedelta(0) + + with session.stream(OriginalRequest(method, url)) as httpx_response: + stream_response = StreamResponse(httpx_response) + assert stream_response.status_code == 200 + assert [chunk for chunk in stream_response.iter_lines()] == ["first line", "second line"] + assert stream_response.elapsed > timedelta(0) + + with session.stream(OriginalRequest(method, url)) as httpx_response: + stream_response = StreamResponse(httpx_response) + assert stream_response.status_code == 200 + assert [chunk for chunk in stream_response.iter_raw(7)] == [b"first l", b"ine\nsec", b"ond lin", b"e\n"] + assert stream_response.elapsed > timedelta(0) + + with session.stream(OriginalRequest(method, url)) as httpx_response: + stream_response = StreamResponse(httpx_response) + assert stream_response.status_code == 200 + assert stream_response.read().decode("utf8") == response_text + assert stream_response.elapsed > timedelta(0) + + +@pytest.mark.parametrize("method", ("GET", "POST", "PUT", "DELETE", "PATCH")) +def test_elapsed_not_available_until_closed( + method: str, + session: HttpxSession, + httpx_mock: HTTPXMock, +): + response_text = "first line\nsecond line\n" + url = "http://example.com/foo/bar" + + httpx_mock.add_response( + method=method.upper(), + url=url, + stream=ByteStream(response_text.encode("utf8")), + ) + + with session.stream(OriginalRequest(method, url)) as httpx_response: + stream_response = StreamResponse(httpx_response) + assert stream_response.status_code == 200 + with pytest.raises(RuntimeError) as error: + stream_response.elapsed + assert str(error.value) == "'.elapsed' may only be accessed after the response has been read or closed." diff --git a/tests/httptoolkit/transport/httpx_transport/test_async_transport.py b/tests/httptoolkit/transport/httpx_transport/test_async_transport.py new file mode 100644 index 0000000..42cc5af --- /dev/null +++ b/tests/httptoolkit/transport/httpx_transport/test_async_transport.py @@ -0,0 +1,396 @@ +import json +import logging +from datetime import timedelta +from json import JSONEncoder +from typing import BinaryIO, Type + +import pytest +from pytest_httpx import HTTPXMock +from testfixtures import LogCapture + +from httptoolkit import HttpMethod +from httptoolkit.errors import TransportError +from httptoolkit.request import Request +from httptoolkit.transport import AsyncHttpxTransport + +HTTPX_CLIENT_STATE_OPENED = 2 + + +@pytest.fixture +def async_transport() -> AsyncHttpxTransport: + return AsyncHttpxTransport( + base_url="https://example.com:4321", + allow_unverified_peer=False, + open_timeout_in_seconds=1, + read_timeout_in_seconds=1, + retry_max_attempts=1, + retry_backoff_factor=0, + allow_post_retry=False, + proxies={}, + ) + + +@pytest.fixture +def async_transport_with_custom_encoder(custom_json_encoder: Type[JSONEncoder]) -> AsyncHttpxTransport: + return AsyncHttpxTransport( + base_url="https://example.com:4321", + allow_unverified_peer=False, + open_timeout_in_seconds=1, + read_timeout_in_seconds=1, + retry_max_attempts=1, + retry_backoff_factor=0, + allow_post_retry=False, + proxies={}, + json_encoder=lambda content: json.dumps(content, cls=custom_json_encoder), + ) + + +@pytest.mark.asyncio +async def test_send_get_request( + get_request, + async_transport: AsyncHttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + method="GET", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = await async_transport.send(get_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"" + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +@pytest.mark.asyncio +async def test_send_post_request( + post_request, + async_transport: AsyncHttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + method="POST", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = await async_transport.send(post_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"It always seems impossible until it's done." + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +@pytest.mark.asyncio +async def test_send_post_request_json( + x_request_dict_json, + async_transport: AsyncHttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + method, x_request = x_request_dict_json + httpx_mock.add_response( + method=method, + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = await async_transport.send(x_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].headers["Content-Type"] == "application/json" + assert calls[0].content == b'{"param1": 1, "param2": 2}' + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +@pytest.mark.asyncio +async def test_send_post_request_list_json( + x_request_list_json, + async_transport: AsyncHttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + method, x_request = x_request_list_json + httpx_mock.add_response( + method=method, + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = await async_transport.send(x_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].headers["Content-Type"] == "application/json" + assert calls[0].content == b'[{"param1": 1, "param2": 2}, {"param3": 3, "param4": 4}]' + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +@pytest.mark.asyncio +async def test_send_post_request_custom_json( + x_custom_request_json, + async_transport_with_custom_encoder: AsyncHttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + method, x_request = x_custom_request_json + httpx_mock.add_response( + method=method, + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = await async_transport_with_custom_encoder.send(x_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].headers["Content-Type"] == "application/json" + assert calls[0].content == b'{"param1": 1, "param2": 2, "time": "07/17/2023", "decimal": "0.5656"}' + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +@pytest.mark.asyncio +async def test_send_post_request_file( + x_request_file, + async_transport: AsyncHttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + method, x_request = x_request_file + httpx_mock.add_response( + method=method, + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = await async_transport.send(x_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].headers["Content-Type"] == "multipart/form-data; boundary=secretboundary" + assert calls[0].headers["Content-Length"] == "235" + assert calls[0].content == ( + b'--secretboundary\r\nContent-Disposition: form-data; name="data"\r\n\r\nSta' + b"llone is a Woman\r\n--secretboundary\r\nContent-Disposition: form-data; name" + b'="upload-file"; filename="test.csv"\r\nContent-Type: text/csv\r\n\r\nHi, w' + b"orld!\r\n--secretboundary--\r\n" + ) + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +@pytest.mark.asyncio +async def test_send_post_request_file_without_content_type( + async_transport: AsyncHttpxTransport, httpx_mock: HTTPXMock, test_file: BinaryIO +) -> None: + request = Request(HttpMethod.POST, "/", {}, files={"upload-file": test_file}) + httpx_mock.add_response(method="POST") + await async_transport.send(request) + + sent = httpx_mock.get_request() + boundary = sent.headers["Content-Type"].split("boundary=")[-1] + boundary_bytes = boundary.encode("ascii") + + assert sent.headers["Content-Type"] == f"multipart/form-data; boundary={boundary}" + assert sent.headers["Content-Length"] == "185" + assert sent.content == ( + b"--" + boundary_bytes + b"\r\n" + b'Content-Disposition: form-data; name="upload-file"; filename="test.csv"\r\n' + b"Content-Type: text/csv\r\n\r\n" + b"Hi, world!\r\n" + b"--" + boundary_bytes + b"--\r\n" + ) + assert sent.extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +@pytest.mark.asyncio +async def test_send_post_request_forms( + async_transport: AsyncHttpxTransport, httpx_mock: HTTPXMock, test_file: BinaryIO +) -> None: + request = Request(HttpMethod.POST, "/", {}, files={"upload-file": ("test.csv", test_file, "text/csv")}) + httpx_mock.add_response(method="POST") + await async_transport.send(request) + + sent = httpx_mock.get_request() + boundary = sent.headers["Content-Type"].split("boundary=")[-1] + boundary_bytes = boundary.encode("ascii") + + assert sent.headers["Content-Type"] == f"multipart/form-data; boundary={boundary}" + assert sent.headers["Content-Length"] == "185" + assert sent.content == ( + b"--" + boundary_bytes + b"\r\n" + b'Content-Disposition: form-data; name="upload-file"; filename="test.csv"\r\n' + b"Content-Type: text/csv\r\n\r\n" + b"Hi, world!\r\n" + b"--" + boundary_bytes + b"--\r\n" + ) + assert sent.extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +@pytest.mark.asyncio +async def test_send_post_request_error_json_and_files( + async_transport: AsyncHttpxTransport, httpx_mock: HTTPXMock, test_file: BinaryIO +) -> None: + request = Request(HttpMethod.POST, "/", {}, files={"upload-file": test_file}, json={"param1": 1, "param2": 2}) + with pytest.raises(RuntimeError) as error: + await async_transport.send(request) + assert str(error.value) == "json and files can't be sent together" + + +@pytest.mark.asyncio +async def test_send_post_request_error_json_and_body( + async_transport: AsyncHttpxTransport, httpx_mock: HTTPXMock, test_file: BinaryIO +) -> None: + request = Request(HttpMethod.POST, "/", {}, body="Stallone is a Woman!", json={"param1": 1, "param2": 2}) + with pytest.raises(RuntimeError) as error: + await async_transport.send(request) + assert str(error.value) == "json and body can't be sent together" + + +@pytest.mark.asyncio +async def test_send_patch_request( + patch_request, + async_transport: AsyncHttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + method="PATCH", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = await async_transport.send(patch_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"It always seems impossible until it's done." + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +@pytest.mark.asyncio +async def test_request_when_error_occurs( + get_request, + async_transport: AsyncHttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_exception( + method="GET", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + exception=Exception("Unexpected!"), + ) + + with pytest.raises(TransportError) as error: + await async_transport.send(get_request) + + assert str(error.value) == "Exception: Unexpected!" + + +@pytest.mark.asyncio +async def test_logging_request( + post_request, + async_transport: AsyncHttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + method="POST", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + with LogCapture(level=logging.INFO) as capture: + await async_transport.send(post_request) + + capture.check( + ( + "httptoolkit.transport._httpx._async", + "INFO", + "Sending POST https://example.com:4321/put/some/data/here?please=True&carefully=True (body: 46)", + ) + ) + + +@pytest.mark.asyncio +async def test_session_is_not_closed_after_response( + get_request, + async_transport: AsyncHttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + method="GET", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + await async_transport.send(get_request) + + assert async_transport._session._state.value == HTTPX_CLIENT_STATE_OPENED + + +@pytest.mark.asyncio +async def test_session_is_not_closed_after_error( + get_request, + async_transport: AsyncHttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + exception_message = "Something went wrong" + httpx_mock.add_exception( + method="GET", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + exception=Exception(exception_message), + ) + + with pytest.raises(Exception) as exception_info: + await async_transport.send(get_request) + + assert str(exception_info.value) == "Exception: Something went wrong" + assert async_transport._session._state.value == HTTPX_CLIENT_STATE_OPENED + + +def test_allow_post_retry(async_transport) -> None: + transport_allow_post_retry = AsyncHttpxTransport( + base_url="", + allow_unverified_peer=False, + open_timeout_in_seconds=1, + read_timeout_in_seconds=1, + retry_max_attempts=1, + retry_backoff_factor=0, + allow_post_retry=True, + proxies={}, + ) + + assert "POST" not in async_transport._session._retry_manager._methods + + assert "POST" in transport_allow_post_retry._session._retry_manager._methods diff --git a/tests/httptoolkit/transport/httpx_transport/test_transport.py b/tests/httptoolkit/transport/httpx_transport/test_transport.py new file mode 100644 index 0000000..33630ee --- /dev/null +++ b/tests/httptoolkit/transport/httpx_transport/test_transport.py @@ -0,0 +1,526 @@ +import json +import logging +import uuid +from datetime import timedelta +from json import JSONEncoder +from typing import BinaryIO, Type + +import pytest +from pytest_httpx import HTTPXMock +from testfixtures import LogCapture + +from httptoolkit import Header, HttpMethod +from httptoolkit.errors import TransportError +from httptoolkit.request import Request +from httptoolkit.transport import HttpxTransport + +HTTPX_CLIENT_STATE_OPENED = 2 + + +@pytest.fixture +def transport() -> HttpxTransport: + return HttpxTransport( + base_url="https://example.com:4321", + allow_unverified_peer=False, + open_timeout_in_seconds=1, + read_timeout_in_seconds=1, + retry_max_attempts=1, + retry_backoff_factor=0, + allow_post_retry=False, + proxies={"http://": "http://10.10.1.10:3128"}, + ) + + +@pytest.fixture +def transport_with_custom_encoder(custom_json_encoder: Type[JSONEncoder]) -> HttpxTransport: + return HttpxTransport( + base_url="https://example.com:4321", + allow_unverified_peer=False, + open_timeout_in_seconds=1, + read_timeout_in_seconds=1, + retry_max_attempts=1, + retry_backoff_factor=0, + allow_post_retry=False, + proxies={}, + json_encoder=lambda content: json.dumps(content, cls=custom_json_encoder), + ) + + +@pytest.fixture +def transport_with_custom_retry_status_codes(custom_json_encoder: Type[JSONEncoder]) -> HttpxTransport: + return HttpxTransport( + base_url="https://example.com:4321", + allow_unverified_peer=False, + open_timeout_in_seconds=1, + read_timeout_in_seconds=1, + retry_max_attempts=5, + retry_backoff_factor=0.1, + allow_post_retry=False, + proxies={"http://": "http://10.10.1.10:3128"}, + retry_status_codes=[404], + ) + + +@pytest.fixture +def transport_with_empty_retry_status_codes(custom_json_encoder: Type[JSONEncoder]) -> HttpxTransport: + return HttpxTransport( + base_url="https://example.com:4321", + allow_unverified_peer=False, + open_timeout_in_seconds=1, + read_timeout_in_seconds=1, + retry_max_attempts=5, + retry_backoff_factor=0.1, + allow_post_retry=False, + proxies={"http://": "http://10.10.1.10:3128"}, + retry_status_codes=[], + ) + + +@pytest.fixture +def transport_with_default_retry_status_codes(custom_json_encoder: Type[JSONEncoder]) -> HttpxTransport: + return HttpxTransport( + base_url="https://example.com:4321", + allow_unverified_peer=False, + open_timeout_in_seconds=1, + read_timeout_in_seconds=1, + retry_max_attempts=5, + retry_backoff_factor=0.1, + allow_post_retry=False, + proxies={"http://": "http://10.10.1.10:3128"}, + ) + + +def test_send_get_request( + get_request, + transport: HttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + method="GET", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = transport.send(get_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"" + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +def test_send_post_request( + post_request, + transport: HttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + method="POST", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = transport.send(post_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"It always seems impossible until it's done." + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +def test_send_post_request_dict_json( + x_request_dict_json, + transport: HttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + method, x_request = x_request_dict_json + httpx_mock.add_response( + method=method, + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = transport.send(x_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].headers["Content-Type"] == "application/json" + assert calls[0].headers["Content-Length"] == "26" + assert calls[0].content == b'{"param1": 1, "param2": 2}' + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +def test_send_post_request_list_json( + x_request_list_json, + transport: HttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + method, x_request = x_request_list_json + httpx_mock.add_response( + method=method, + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = transport.send(x_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].headers["Content-Type"] == "application/json" + assert calls[0].headers["Content-Length"] == "56" + assert calls[0].content == b'[{"param1": 1, "param2": 2}, {"param3": 3, "param4": 4}]' + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +def test_send_post_request_custom_json( + x_custom_request_json, + transport_with_custom_encoder: HttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + method, x_request = x_custom_request_json + httpx_mock.add_response( + method=method, + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + returned_request, response = transport_with_custom_encoder.send(x_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].headers["Content-Type"] == "application/json" + assert calls[0].headers["Content-Length"] == "69" + assert calls[0].content == b'{"param1": 1, "param2": 2, "time": "07/17/2023", "decimal": "0.5656"}' + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +def test_send_post_request_file( + x_request_file, + transport: HttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + method, x_request = x_request_file + httpx_mock.add_response( + method=method, + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = transport.send(x_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].headers["Content-Type"] == "multipart/form-data; boundary=secretboundary" + assert calls[0].headers["Content-Length"] == "235" + assert calls[0].content == ( + b'--secretboundary\r\nContent-Disposition: form-data; name="data"\r\n\r\nSta' + b"llone is a Woman\r\n--secretboundary\r\nContent-Disposition: form-data; name" + b'="upload-file"; filename="test.csv"\r\nContent-Type: text/csv\r\n\r\nHi, w' + b"orld!\r\n--secretboundary--\r\n" + ) + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +def test_send_post_request_file_without_content_type( + transport: HttpxTransport, httpx_mock: HTTPXMock, test_file: BinaryIO +) -> None: + request = Request(HttpMethod.POST, "/", {}, files={"upload-file": test_file}) + httpx_mock.add_response(method="POST") + transport.send(request) + + sent = httpx_mock.get_request() + boundary = sent.headers["Content-Type"].split("boundary=")[-1] + boundary_bytes = boundary.encode("ascii") + + assert sent.headers["Content-Type"] == f"multipart/form-data; boundary={boundary}" + assert sent.headers["Content-Length"] == "185" + assert sent.content == ( + b"--" + boundary_bytes + b"\r\n" + b'Content-Disposition: form-data; name="upload-file"; filename="test.csv"\r\n' + b"Content-Type: text/csv\r\n\r\n" + b"Hi, world!\r\n" + b"--" + boundary_bytes + b"--\r\n" + ) + assert sent.extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +def test_send_post_request_forms(transport: HttpxTransport, httpx_mock: HTTPXMock, test_file: BinaryIO) -> None: + request = Request(HttpMethod.POST, "/", {}, files={"upload-file": ("test.csv", test_file, "text/csv")}) + httpx_mock.add_response(method="POST") + transport.send(request) + + sent = httpx_mock.get_request() + boundary = sent.headers["Content-Type"].split("boundary=")[-1] + boundary_bytes = boundary.encode("ascii") + + assert sent.headers["Content-Type"] == f"multipart/form-data; boundary={boundary}" + assert sent.headers["Content-Length"] == "185" + assert sent.content == ( + b"--" + boundary_bytes + b"\r\n" + b'Content-Disposition: form-data; name="upload-file"; filename="test.csv"\r\n' + b"Content-Type: text/csv\r\n\r\n" + b"Hi, world!\r\n" + b"--" + boundary_bytes + b"--\r\n" + ) + assert sent.extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +def test_send_post_request_error_json_and_files( + transport: HttpxTransport, httpx_mock: HTTPXMock, test_file: BinaryIO +) -> None: + request = Request(HttpMethod.POST, "/", {}, files={"upload-file": test_file}, json={"param1": 1, "param2": 2}) + with pytest.raises(RuntimeError) as error: + transport.send(request) + assert str(error.value) == "json and files can't be sent together" + + +def test_send_post_request_error_json_and_body( + transport: HttpxTransport, httpx_mock: HTTPXMock, test_file: BinaryIO +) -> None: + request = Request(HttpMethod.POST, "/", {}, body="Stallone is a Woman!", json={"param1": 1, "param2": 2}) + with pytest.raises(RuntimeError) as error: + transport.send(request) + assert str(error.value) == "json and body can't be sent together" + + +def test_sends_serialized_uuid( + transport: HttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + request = Request( + method=HttpMethod.POST, + path="/put/some/data/here", + json={"user_id": uuid.UUID("9d1e286b-3ee0-4fb5-813e-6125f5b5d1b5")}, + params={}, + headers=(Header(name="ServiceHeader", value="service-header", is_sensitive=False),), + ) + + httpx_mock.add_response( + method="POST", + url="https://example.com:4321/put/some/data/here", + ) + + transport.send(request) + + httpx_request = httpx_mock.get_request() + assert httpx_request.headers["Content-Type"] == "application/json" + assert httpx_request.content == b'{"user_id": "9d1e286b-3ee0-4fb5-813e-6125f5b5d1b5"}' + assert httpx_request.extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +def test_send_patch_request( + patch_request, + transport: HttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + method="PATCH", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + returned_request, response = transport.send(patch_request) + + assert response.text == "Schwarzenegger is a woman!" + assert response.elapsed > timedelta(0) + + calls = httpx_mock.get_requests() + + assert calls[0].headers["ServiceHeader"] == "service-header" + assert calls[0].headers["RequestHeader"] == "request-header" + assert calls[0].content == b"It always seems impossible until it's done." + assert calls[0].extensions["timeout"] == {"connect": 1, "pool": None, "read": 1, "write": None} + + +def test_request_when_error_occurs( + get_request, + transport: HttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_exception( + method="GET", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + exception=Exception("Unexpected!"), + ) + + with pytest.raises(TransportError) as error: + transport.send(get_request) + + assert str(error.value) == "Exception: Unexpected!" + + +def test_logging_request( + post_request, + transport: HttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + method="POST", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + with LogCapture(level=logging.INFO) as capture: + transport.send(post_request) + + capture.check( + ( + "httptoolkit.transport._httpx._sync", + "INFO", + "Sending POST https://example.com:4321/put/some/data/here?please=True&carefully=True (body: 46)", + ) + ) + + +def test_session_is_not_closed_after_response( + get_request, + transport: HttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + method="GET", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + text="Schwarzenegger is a woman!", + ) + + transport.send(get_request) + + assert transport._session._state.value == HTTPX_CLIENT_STATE_OPENED + + +def test_session_is_not_closed_after_error( + get_request, + transport: HttpxTransport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_exception( + method="GET", + url="https://example.com:4321/put/some/data/here?please=True&carefully=True", + exception=Exception("Something went wrong"), + ) + + with pytest.raises(Exception) as exception_info: + transport.send(get_request) + + assert str(exception_info.value) == "Exception: Something went wrong" + assert transport._session._state.value == HTTPX_CLIENT_STATE_OPENED + + +def test_allow_post_retry(transport) -> None: + transport_allow_post_retry = HttpxTransport( + base_url="", + allow_unverified_peer=False, + open_timeout_in_seconds=1, + read_timeout_in_seconds=1, + retry_max_attempts=1, + retry_backoff_factor=0, + allow_post_retry=True, + proxies={}, + ) + + assert "POST" not in transport._session._retry_manager._methods + + assert "POST" in transport_allow_post_retry._session._retry_manager._methods + + +def test_returned_request( + transport, + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response() + + request = Request( + method=HttpMethod.GET, + path="", + headers=( + Header(name="Sensitive", value="Sensitive", is_sensitive=True), + Header(name="CamelCase", value="CamelCase", is_sensitive=False), + Header(name="Kebab-Case", value="Kebab-Case", is_sensitive=False), + Header(name="Snake_Case", value="Snake_Case", is_sensitive=False), + ), + params={}, + body="Content", + ) + returned_request, _ = transport.send(request) + + assert sorted(returned_request.headers, key=lambda i: i.name) == [ + Header(name="accept", value="*/*", is_sensitive=False), + Header(name="accept-encoding", value="gzip, deflate", is_sensitive=False), + Header(name="camelcase", value="CamelCase", is_sensitive=False), + Header(name="connection", value="keep-alive", is_sensitive=False), + Header(name="content-length", value="7", is_sensitive=False), + Header(name="host", value="example.com:4321", is_sensitive=False), + Header(name="kebab-case", value="Kebab-Case", is_sensitive=False), + Header(name="sensitive", value="Sensitive", is_sensitive=True), + Header(name="snake_case", value="Snake_Case", is_sensitive=False), + Header(name="user-agent", value="python-httpx/0.24.1", is_sensitive=False), + ] + assert returned_request.url == "https://example.com:4321/" + assert returned_request.method == "GET" + assert returned_request.body == b"Content" + assert returned_request.proxies == {"http://": "http://10.10.1.10:3128"} + + +def test_custom_retry_status_codes( + get_request, httpx_mock: HTTPXMock, transport_with_custom_retry_status_codes: HttpxTransport +) -> None: + httpx_mock.add_response(method="GET", url="https://example.com:4321/put/some/data/here", status_code=404) + httpx_mock.add_response(method="GET", url="https://example.com:4321/put/some/data/here", status_code=200) + + request = Request(method=HttpMethod.GET, path="/put/some/data/here", params={}) + + sent_request, response = transport_with_custom_retry_status_codes.send(request) + + assert response.status_code == 200 + + +def test_empty_retry_status_codes( + get_request, httpx_mock: HTTPXMock, transport_with_empty_retry_status_codes: HttpxTransport +) -> None: + httpx_mock.add_response(method="GET", url="https://example.com:4321/put/some/data/here", status_code=413) + + request = Request(method=HttpMethod.GET, path="/put/some/data/here", params={}) + sent_request, response = transport_with_empty_retry_status_codes.send(request) + + assert response.status_code == 413 + + +def test_default_retry_status_codes( + get_request, httpx_mock: HTTPXMock, transport_with_default_retry_status_codes: HttpxTransport +) -> None: + httpx_mock.add_response(method="GET", url="https://example.com:4321/put/some/data/here", status_code=413) + httpx_mock.add_response(method="GET", url="https://example.com:4321/put/some/data/here", status_code=200) + + request = Request(method=HttpMethod.GET, path="/put/some/data/here", params={}) + + sent_request, response = transport_with_default_retry_status_codes.send(request) + + assert response.status_code == 200 diff --git a/tests/httptoolkit/transport/test_base_transport.py b/tests/httptoolkit/transport/test_base_transport.py new file mode 100644 index 0000000..6122faf --- /dev/null +++ b/tests/httptoolkit/transport/test_base_transport.py @@ -0,0 +1,22 @@ +import pytest + +from httptoolkit.transport import BaseHttpxTransport + + +def test_abstract_httpx_transport_cannot_instantiate(): + with pytest.raises(TypeError) as exception_info: + BaseHttpxTransport( + base_url="", + allow_unverified_peer=False, + open_timeout_in_seconds=1, + read_timeout_in_seconds=1, + retry_max_attempts=1, + retry_backoff_factor=0, + allow_post_retry=False, + proxies={}, + ) + exception_message = str(exception_info.value) + abstract_methods = ("_session_class",) + print(exception_message) + + assert all(abstract_method in exception_message for abstract_method in abstract_methods) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..6e00221 --- /dev/null +++ b/tox.ini @@ -0,0 +1,48 @@ +[tox] +envlist = pyproject,flake8,mypy,py3{9,10,11,12,13} + +[testenv] +setenv = + PYTHONUTF8 = 1 + REPORT_NAME = {env:TOX_ENV:{envname}} + +[testenv:py3{9,10,11,12,13}] +extras = + test +allowlist_externals = + coverage +commands = + pytest \ + --cov httptoolkit \ + --cov-report term-missing \ + --cov-append \ + --junitxml=.reports/{env:REPORT_NAME}_junit.xml \ + {posargs} +commands_post = + coverage xml -o .reports/{env:REPORT_NAME}_coverage.xml + +[testenv:pyproject] +skip_install = true +deps = + validate-pyproject[all]~=0.12.1 +commands = + validate-pyproject pyproject.toml + +[testenv:mypy] +skip_install = true +deps = + mypy~=1.1.1 +commands = + mypy --junit-xml .reports/{env:REPORT_NAME}_junit.xml . + +[testenv:flake8] +skip_install = true +deps = + flake8~=6.0.0 + flake8-pyproject~=1.2.2 + flake8-junit-report~=2.1.0 + flake8-black~=0.3.6 +commands = + flake8 --output-file .reports/{env:REPORT_NAME}_output.txt +commands_post = + flake8_junit .reports/{env:REPORT_NAME}_output.txt .reports/{env:REPORT_NAME}_junit.xml