diff --git a/mocket/mocket.py b/mocket/mocket.py index a01a7b46..c9e6e204 100644 --- a/mocket/mocket.py +++ b/mocket/mocket.py @@ -4,7 +4,7 @@ import itertools import os from pathlib import Path -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar import mocket.inject from mocket.recording import MocketRecordStorage @@ -99,12 +99,12 @@ def reset(cls) -> None: cls._record_storage = None @classmethod - def last_request(cls): + def last_request(cls) -> Any: if cls.has_requests(): return cls._requests[-1] @classmethod - def request_list(cls): + def request_list(cls) -> list[Any]: return cls._requests @classmethod diff --git a/mocket/mocks/mockhttp.py b/mocket/mocks/mockhttp.py index 245a11af..3db6a65d 100644 --- a/mocket/mocks/mockhttp.py +++ b/mocket/mocks/mockhttp.py @@ -88,7 +88,7 @@ def __init__(self, body="", status=200, headers=None): self.data = self.get_protocol_data() + self.body - def get_protocol_data(self, str_format_fun_name="capitalize"): + def get_protocol_data(self, str_format_fun_name: str = "capitalize") -> bytes: status_line = f"HTTP/1.1 {self.status} {STATUS[self.status]}" header_lines = CRLF.join( ( diff --git a/mocket/plugins/httpretty/__init__.py b/mocket/plugins/httpretty/__init__.py index fac61840..97a2c3a4 100644 --- a/mocket/plugins/httpretty/__init__.py +++ b/mocket/plugins/httpretty/__init__.py @@ -1,3 +1,5 @@ +from typing import Any, Dict, Optional + from mocket import mocketize from mocket.async_mocket import async_mocketize from mocket.compat import ENCODING @@ -7,33 +9,35 @@ from mocket.mockhttp import Response as MocketHttpResponse -def httprettifier_headers(headers): +def httprettifier_headers(headers: Dict[str, str]) -> Dict[str, str]: return {k.lower().replace("_", "-"): v for k, v in headers.items()} class Request(MocketHttpRequest): @property - def body(self): - return super().body.encode(ENCODING) + def body(self) -> bytes: + return super().body.encode(ENCODING) # type: ignore[no-any-return] @property - def headers(self): + def headers(self) -> Dict[str, str]: return httprettifier_headers(super().headers) class Response(MocketHttpResponse): - def get_protocol_data(self, str_format_fun_name="lower"): + headers: Dict[str, str] + + def get_protocol_data(self, str_format_fun_name: str = "lower") -> bytes: if "server" in self.headers and self.headers["server"] == "Python/Mocket": self.headers["server"] = "Python/HTTPretty" - return super().get_protocol_data(str_format_fun_name=str_format_fun_name) + return super().get_protocol_data(str_format_fun_name=str_format_fun_name) # type: ignore[no-any-return] - def set_base_headers(self): + def set_base_headers(self) -> None: super().set_base_headers() self.headers = httprettifier_headers(self.headers) original_set_base_headers = set_base_headers - def set_extra_headers(self, headers): + def set_extra_headers(self, headers: Dict[str, str]) -> None: self.headers.update(headers) @@ -60,17 +64,17 @@ class Entry(MocketHttpEntry): def register_uri( - method, - uri, - body="HTTPretty :)", - adding_headers=None, - forcing_headers=None, - status=200, - responses=None, - match_querystring=False, - priority=0, - **headers, -): + method: str, + uri: str, + body: str = "HTTPretty :)", + adding_headers: Optional[Dict[str, str]] = None, + forcing_headers: Optional[Dict[str, str]] = None, + status: int = 200, + responses: Any = None, + match_querystring: bool = False, + priority: int = 0, + **headers: str, +) -> None: headers = httprettifier_headers(headers) if adding_headers is not None: @@ -81,9 +85,9 @@ def register_uri( def force_headers(self): self.headers = httprettifier_headers(forcing_headers) - Response.set_base_headers = force_headers + Response.set_base_headers = force_headers # type: ignore[method-assign] else: - Response.set_base_headers = Response.original_set_base_headers + Response.set_base_headers = Response.original_set_base_headers # type: ignore[method-assign] if responses: Entry.register(method, uri, *responses) @@ -110,7 +114,7 @@ def __getattr__(self, name): HTTPretty = MocketHTTPretty() -HTTPretty.register_uri = register_uri +HTTPretty.register_uri = register_uri # type: ignore[attr-defined] httpretty = HTTPretty __all__ = ( diff --git a/mocket/utils.py b/mocket/utils.py index 60ddd9f2..6180ae3f 100644 --- a/mocket/utils.py +++ b/mocket/utils.py @@ -2,12 +2,34 @@ import binascii import contextlib -from typing import Callable +from typing import Any, Callable, Protocol, TypeVar, overload import decorator +from typing_extensions import ParamSpec from mocket.compat import decode_from_bytes, encode_to_bytes +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +class MocketizeDecorator(Protocol): + """ + This is a generic decorator signature, currently applicable to get_mocketize. + + Decorators can be used as: + 1. A function that transforms func (the parameter) into func1 (the returned object). + 2. A function that takes keyword arguments and returns 1. + """ + + @overload + def __call__(self, func: Callable[_P, _R], /) -> Callable[_P, _R]: ... + + @overload + def __call__( + self, **kwargs: Any + ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ... + def hexdump(binary_string: bytes) -> str: r""" @@ -30,11 +52,11 @@ def hexload(string: str) -> bytes: raise ValueError from e -def get_mocketize(wrapper_: Callable) -> Callable: +def get_mocketize(wrapper_: Callable) -> MocketizeDecorator: # trying to support different versions of `decorator` with contextlib.suppress(TypeError): - return decorator.decorator(wrapper_, kwsyntax=True) # type: ignore[call-arg,unused-ignore] - return decorator.decorator(wrapper_) + return decorator.decorator(wrapper_, kwsyntax=True) # type: ignore[return-value, call-arg, unused-ignore] + return decorator.decorator(wrapper_) # type: ignore[return-value] __all__ = ( diff --git a/pyproject.toml b/pyproject.toml index 6872741f..b8631517 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ test = [ "wait-for-it", "mypy", "types-decorator", + "types-requests", ] speedups = [ "xxhash;platform_python_implementation=='CPython'", @@ -123,6 +124,9 @@ files = [ "mocket/exceptions.py", "mocket/compat.py", "mocket/utils.py", + "mocket/plugins/httpretty/__init__.py", + "tests/test_httpretty.py", + "tests/test_mocket_utils.py", # "tests/" ] strict = true @@ -140,3 +144,11 @@ disable_error_code = ["no-untyped-def"] # enable this once full type-coverage is [[tool.mypy.overrides]] module = "tests.*" disable_error_code = ['type-arg', 'no-untyped-def'] + +[[tool.mypy.overrides]] +module = "mocket.plugins.*" +disallow_subclassing_any = false # mypy doesn't support dynamic imports + +[[tool.mypy.overrides]] +module = "tests.test_httpretty" +disallow_untyped_decorators = true diff --git a/tests/test_mocket_utils.py b/tests/test_mocket_utils.py new file mode 100644 index 00000000..d3b5eba7 --- /dev/null +++ b/tests/test_mocket_utils.py @@ -0,0 +1,31 @@ +from typing import Callable +from unittest import TestCase +from unittest.mock import NonCallableMock, patch + +import decorator + +from mocket.utils import get_mocketize + + +def mock_decorator(func: Callable[[], None]) -> None: + return func() + + +class GetMocketizeTestCase(TestCase): + @patch.object(decorator, "decorator") + def test_get_mocketize_with_kwsyntax(self, dec: NonCallableMock) -> None: + get_mocketize(mock_decorator) + dec.assert_called_once_with(mock_decorator, kwsyntax=True) + + @patch.object(decorator, "decorator") + def test_get_mocketize_without_kwsyntax(self, dec: NonCallableMock) -> None: + dec.side_effect = [ + TypeError("kwsyntax is not supported in this version of decorator"), + mock_decorator, + ] + + get_mocketize(mock_decorator) + # First time called with kwsyntax=True, which failed with TypeError + dec.call_args_list[0].assert_compare_to((mock_decorator,), {"kwsyntax": True}) + # Second time without kwsyntax, which succeeds + dec.call_args_list[1].assert_compare_to((mock_decorator,))