Repository: lundberg/respx
Pull request #201 (2022-03-07 12:43:31)

Enable `strict_optional`

codecov-commenter: # [Codecov](https://codecov.io/gh/lundberg/respx/pull/201?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg) Report
> Merging [#201](https://codecov.io/gh/lundberg/respx/pull/201?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg) (b82a0e2) into [master](https://codecov.io/gh/lundberg/respx/commit/c76f632690cdfb2c878fe8fea999df0a26083eb3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg) (c76f632) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/lundberg/respx/pull/201/graphs/tree.svg?width=650&height=150&src=pr&token=wJJ1eMBSK2&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg)](https://codecov.io/gh/lundberg/respx/pull/201?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg)

```diff
@@            Coverage Diff            @@
##            master      #201   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           20        20           
  Lines         2722      2729    +7     
  Branches       336       338    +2     
=========================================
+ Hits          2722      2729    +7     
```


| [Impacted Files](https://codecov.io/gh/lundberg/respx/pull/201?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg) | Coverage Δ | |
|---|---|---|
| [respx/handlers.py](https://codecov.io/gh/lundberg/respx/pull/201/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg#diff-cmVzcHgvaGFuZGxlcnMucHk=) | `100.00% <ø> (ø)` | |
| [respx/transports.py](https://codecov.io/gh/lundberg/respx/pull/201/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg#diff-cmVzcHgvdHJhbnNwb3J0cy5weQ==) | `100.00% <ø> (ø)` | |
| [respx/models.py](https://codecov.io/gh/lundberg/respx/pull/201/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg#diff-cmVzcHgvbW9kZWxzLnB5) | `100.00% <100.00%> (ø)` | |
| [respx/patterns.py](https://codecov.io/gh/lundberg/respx/pull/201/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg#diff-cmVzcHgvcGF0dGVybnMucHk=) | `100.00% <100.00%> (ø)` | |
| [respx/router.py](https://codecov.io/gh/lundberg/respx/pull/201/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg#diff-cmVzcHgvcm91dGVyLnB5) | `100.00% <100.00%> (ø)` | |
| [tests/test\_patterns.py](https://codecov.io/gh/lundberg/respx/pull/201/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg#diff-dGVzdHMvdGVzdF9wYXR0ZXJucy5weQ==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/lundberg/respx/pull/201?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/lundberg/respx/pull/201?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg). Last update [c76f632...b82a0e2](https://codecov.io/gh/lundberg/respx/pull/201?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg).


Patch:
diff --git a/respx/handlers.py b/respx/handlers.py
index 0f2c0f6..0d4ad40 100644
--- a/respx/handlers.py
+++ b/respx/handlers.py
@@ -8,7 +8,10 @@ class TransportHandler:
         self.transport = transport
 
     def __call__(self, request: httpx.Request) -> httpx.Response:
-        if not isinstance(request.stream, httpx.SyncByteStream):  # pragma: nocover
+        if not isinstance(
+            request.stream,  # type: ignore[has-type]
+            httpx.SyncByteStream,
+        ):  # pragma: nocover
             raise RuntimeError("Attempted to route an async request to a sync app.")
 
         return self.transport.handle_request(request)
@@ -19,7 +22,10 @@ class AsyncTransportHandler:
         self.transport = transport
 
     async def __call__(self, request: httpx.Request) -> httpx.Response:
-        if not isinstance(request.stream, httpx.AsyncByteStream):  # pragma: nocover
+        if not isinstance(
+            request.stream,  # type: ignore[has-type]
+            httpx.AsyncByteStream,
+        ):  # pragma: nocover
             raise RuntimeError("Attempted to route a sync request to an async app.")
 
         return await self.transport.handle_async_request(request)
diff --git a/respx/models.py b/respx/models.py
index e588cad..7c15c63 100644
--- a/respx/models.py
+++ b/respx/models.py
@@ -1,16 +1,15 @@
 import inspect
 from typing import (
     Any,
-    Callable,
     Dict,
     Iterator,
     List,
     NamedTuple,
     Optional,
+    Sequence,
     Tuple,
     Type,
     Union,
-    cast,
 )
 from unittest import mock
 from warnings import warn
@@ -24,6 +23,7 @@ from .types import (
     HeaderTypes,
     ResolvedResponseTypes,
     RouteResultTypes,
+    SideEffectListTypes,
     SideEffectTypes,
 )
 
@@ -35,7 +35,7 @@ def clone_response(response: httpx.Response, request: httpx.Request) -> httpx.Re
     response = httpx.Response(
         response.status_code,
         headers=response.headers,
-        stream=response.stream,
+        stream=response.stream,  # type: ignore[has-type]
         request=request,
         extensions=dict(response.extensions),
     )
@@ -90,7 +90,9 @@ class MockResponse(httpx.Response):
                 f"got {content!r}. Please use json=... or side effects."
             )
 
-        super().__init__(status_code or 200, content=content, **kwargs)
+        if content is not None:
+            kwargs["content"] = content
+        super().__init__(status_code or 200, **kwargs)
 
         if content_type:
             self.headers["Content-Type"] = content_type
@@ -176,11 +178,14 @@ class Route:
         return self._side_effect
 
     @side_effect.setter
-    def side_effect(self, side_effect: Optional[SideEffectTypes]) -> None:
+    def side_effect(
+        self,
+        side_effect: Optional[Union[SideEffectTypes, Sequence[SideEffectListTypes]]],
+    ) -> None:
         self.pass_through(False)
         if not side_effect:
             self._side_effect = None
-        elif isinstance(side_effect, (tuple, list, Iterator)):
+        elif isinstance(side_effect, (Iterator, Sequence)):
             self._side_effect = iter(side_effect)
         else:
             self._side_effect = side_effect
@@ -277,14 +282,13 @@ class Route:
 
     def _next_side_effect(
         self,
-    ) -> Union[Callable, Exception, Type[Exception], httpx.Response]:
-        effect: Union[Callable, Exception, Type[Exception], httpx.Response]
+    ) -> Union[CallableSideEffect, Exception, Type[Exception], httpx.Response]:
+        assert self._side_effect is not None
+        effect: Union[CallableSideEffect, Exception, Type[Exception], httpx.Response]
         if isinstance(self._side_effect, Iterator):
             effect = next(self._side_effect)
         else:
-            effect = cast(
-                Union[Callable, Exception, Type[Exception]], self._side_effect
-            )
+            effect = self._side_effect
 
         return effect
 
@@ -328,19 +332,18 @@ class Route:
             raise SideEffectError(self, origin=effect)
 
         # Handle Exception `type` side effect
-        Error: Type[Exception] = cast(Type[Exception], effect)
-        if isinstance(effect, type) and issubclass(Error, Exception):
+        elif isinstance(effect, type) and issubclass(effect, Exception):
             raise SideEffectError(
                 self,
                 origin=(
-                    Error("Mock Error", request=request)
-                    if issubclass(Error, httpx.RequestError)
-                    else Error()
+                    effect("Mock Error", request=request)
+                    if issubclass(effect, httpx.RequestError)
+                    else effect()
                 ),
             )
 
         # Handle `Callable` side effect
-        if callable(effect):
+        elif callable(effect):
             result = self._call_side_effect(effect, request, **kwargs)
             return result
 
@@ -375,10 +378,10 @@ class Route:
         Returns None for a non-matching route, mocked response for a match,
         or input request for pass-through.
         """
-        context = {}
+        context: Dict[str, Any] = {}
 
         if self._pattern:
-            match = self.pattern.match(request)
+            match = self._pattern.match(request)
             if not match:
                 return None
             context = match.context
@@ -436,7 +439,7 @@ class RouteList:
 
     def add(self, route: Route, name: Optional[str] = None) -> Route:
         # Find route with same name
-        existing_route = self._names.pop(name, None)
+        existing_route = self._names.pop(name or "", None)
 
         if route in self._routes:
             if existing_route and existing_route != route:
diff --git a/respx/patterns.py b/respx/patterns.py
index e6e6be0..37ab1d4 100644
--- a/respx/patterns.py
+++ b/respx/patterns.py
@@ -388,8 +388,9 @@ class Path(Pattern):
         return request.url.path
 
     def strip_base(self, value: str) -> str:
-        value = value[len(self.base.value) :]
-        value = "/" + value if not value.startswith("/") else value
+        if self.base:
+            value = value[len(self.base.value) :]
+            value = "/" + value if not value.startswith("/") else value
         return value
 
 
@@ -499,7 +500,7 @@ class Data(ContentMixin, Pattern):
         return data
 
 
-def M(*patterns: Pattern, **lookups: Any) -> Pattern:
+def M(*patterns: Pattern, **lookups: Any) -> Optional[Pattern]:
     extras = None
 
     for pattern__lookup, value in lookups.items():
@@ -545,7 +546,7 @@ def M(*patterns: Pattern, **lookups: Any) -> Pattern:
 
 
 def get_scheme_port(scheme: Optional[str]) -> Optional[int]:
-    return {"http": 80, "https": 443}.get(scheme)
+    return {"http": 80, "https": 443}.get(scheme or "")
 
 
 def combine(
@@ -596,7 +597,7 @@ def parse_url_patterns(
     return bases
 
 
-def merge_patterns(pattern: Pattern, **bases: Pattern) -> Pattern:
+def merge_patterns(pattern: Optional[Pattern], **bases: Pattern) -> Optional[Pattern]:
     if not bases:
         return pattern
 
@@ -606,7 +607,7 @@ def merge_patterns(pattern: Pattern, **bases: Pattern) -> Pattern:
 
         if "host" in (_pattern.key for _pattern in patterns):
             # Pattern is "absolute", skip merging
-            bases = None
+            bases = {}
         else:
             # Traverse pattern and set related base
             for _pattern in patterns:
@@ -619,7 +620,7 @@ def merge_patterns(pattern: Pattern, **bases: Pattern) -> Pattern:
     if bases:
         # Combine left over base patterns with pattern
         base_pattern = combine(list(bases.values()))
-        if pattern:
+        if pattern and base_pattern:
             pattern = base_pattern & pattern
         else:
             pattern = base_pattern
diff --git a/respx/router.py b/respx/router.py
index c6e710d..9c141ce 100644
--- a/respx/router.py
+++ b/respx/router.py
@@ -102,7 +102,7 @@ class Router:
         if any(not route.called for route in self.routes):
             raise AllCalledAssertionError("RESPX: some routes were not called!")
 
-    def __getitem__(self, name: str) -> Route:
+    def __getitem__(self, name: str) -> Optional[Route]:
         return self.routes[name]
 
     @overload
@@ -283,7 +283,9 @@ class Router:
                     resolved.response = cast(ResolvedResponseTypes, prospect)
                     break
 
-        if isinstance(resolved.response.stream, httpx.ByteStream):
+        if resolved.response and isinstance(
+            resolved.response.stream, httpx.ByteStream  # type: ignore[has-type]
+        ):
             resolved.response.read()  # Pre-read stream
 
         return resolved
@@ -305,7 +307,9 @@ class Router:
                     resolved.response = cast(ResolvedResponseTypes, prospect)
                     break
 
-        if isinstance(resolved.response.stream, httpx.ByteStream):
+        if resolved.response and isinstance(
+            resolved.response.stream, httpx.ByteStream  # type: ignore[has-type]
+        ):
             await resolved.response.aread()  # Pre-read stream
 
         return resolved
@@ -322,8 +326,6 @@ class Router:
 
 
 class MockRouter(Router):
-    Mocker: Optional[Type[Mocker]]
-
     def __init__(
         self,
         *,
@@ -337,6 +339,7 @@ class MockRouter(Router):
             assert_all_mocked=assert_all_mocked,
             base_url=base_url,
         )
+        self.Mocker: Optional[Type[Mocker]] = None
         self._using = using
 
     @overload
@@ -399,7 +402,7 @@ class MockRouter(Router):
         needs_mock_reference = "respx_mock" in argspec.args
 
         # Async Decorator
-        async def async_decorator(*args, **kwargs):
+        async def _async_decorator(*args, **kwargs):
             assert func is not None
             if needs_mock_reference:
                 kwargs["respx_mock"] = self
@@ -407,16 +410,18 @@ class MockRouter(Router):
                 return await func(*args, **kwargs)
 
         # Sync Decorator
-        def sync_decorator(*args, **kwargs):
+        def _sync_decorator(*args, **kwargs):
             assert func is not None
             if "respx_mock" in argspec.args:
                 kwargs["respx_mock"] = self
             with self:
                 return func(*args, **kwargs)
 
+        async_decorator = _async_decorator
+        sync_decorator = _sync_decorator
         if not needs_mock_reference:
-            async_decorator = update_wrapper(async_decorator, func)
-            sync_decorator = update_wrapper(sync_decorator, func)
+            async_decorator = update_wrapper(_async_decorator, func)
+            sync_decorator = update_wrapper(_sync_decorator, func)
 
         # Dispatch async/sync decorator, depending on decorated function.
         # - Only stage when using global decorator `@respx.mock`
@@ -429,9 +434,9 @@ class MockRouter(Router):
 
     def __exit__(
         self,
-        exc_type: Type[BaseException] = None,
-        exc_value: BaseException = None,
-        traceback: TracebackType = None,
+        exc_type: Optional[Type[BaseException]] = None,
+        exc_value: Optional[BaseException] = None,
+        traceback: Optional[TracebackType] = None,
     ) -> None:
         self.stop(quiet=bool(exc_type is not None))
 
@@ -461,7 +466,7 @@ class MockRouter(Router):
         Register transport, snapshot router and start patching.
         """
         self.snapshot()
-        self.Mocker = Mocker.registry.get(self.using)
+        self.Mocker = Mocker.registry.get(self.using or "")
         if self.Mocker:
             self.Mocker.register(self)
             self.Mocker.start()
diff --git a/respx/transports.py b/respx/transports.py
index 15e432b..c3bf767 100644
--- a/respx/transports.py
+++ b/respx/transports.py
@@ -55,9 +55,9 @@ class MockTransport(httpx.MockTransport):
 
     def __exit__(
         self,
-        exc_type: Type[BaseException] = None,
-        exc_value: BaseException = None,
-        traceback: TracebackType = None,
+        exc_type: Optional[Type[BaseException]] = None,
+        exc_value: Optional[BaseException] = None,
+        traceback: Optional[TracebackType] = None,
     ) -> None:
         if not exc_type and self._router and self._router._assert_all_called:
             self._router.assert_all_called()
diff --git a/respx/types.py b/respx/types.py
index cdf7c07..0ce2101 100644
--- a/respx/types.py
+++ b/respx/types.py
@@ -51,6 +51,5 @@ SideEffectTypes = Union[
     CallableSideEffect,
     Exception,
     Type[Exception],
-    Sequence[SideEffectListTypes],
     Iterator[SideEffectListTypes],
 ]
diff --git a/setup.cfg b/setup.cfg
index de31760..5658510 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -38,7 +38,7 @@ show_missing = True
 no_implicit_reexport = True
 no_implicit_optional = True
 strict_equality = True
-strict_optional = False
+strict_optional = True
 check_untyped_defs = True
 disallow_incomplete_defs = True
 ignore_missing_imports = False
@@ -48,5 +48,7 @@ warn_redundant_casts = True
 warn_unused_ignores = True
 warn_unreachable = True
 
+show_error_codes = True
+
 [mypy-pytest.*]
 ignore_missing_imports = True


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 3bae9bb..f08f1ab 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -303,7 +303,7 @@ async def test_callable_content(client):
 
         def content_callback(request, slug):
             content = jsonlib.loads(request.content)
-            return respx.MockResponse(text=f"hello {slug}{content['x']}")
+            return respx.MockResponse(content=f"hello {slug}{content['x']}")
 
         request = respx_mock.post(url_pattern)
         request.side_effect = content_callback
diff --git a/tests/test_patterns.py b/tests/test_patterns.py
index da78fc1..0f06293 100644
--- a/tests/test_patterns.py
+++ b/tests/test_patterns.py
@@ -191,6 +191,11 @@ def test_path_pattern():
     request = httpx.Request("GET", "https://foo.bar/baz/")
     assert Path(["/egg/", "/baz/"], lookup=Lookup.IN).match(request)
 
+    path = Path("/bar/")
+    assert path.strip_base("/foo/bar/") == "/foo/bar/"
+    path.base = Path("/foo/")
+    assert path.strip_base("/foo/bar/") == "/bar/"
+
 
 @pytest.mark.parametrize(
     "lookup,params,url,expected",