Skip to content

Commit 207cd16

Browse files
authored
Reduce number of Optional return values (#217)
* Disallow slice assigning routes * type CallList * Remove unnececary backend arg * type side_effect setter * configure mypy - include tests for mypy * Test python 3.11 * Make Call.response non optional, raise instead * assert resolved.response is not None * type ignore bad usage * use == instead of is to be reachable * Introduce Noop to make pattern non optional * Add Call.has_response helper
1 parent 6403618 commit 207cd16

File tree

10 files changed

+141
-55
lines changed

10 files changed

+141
-55
lines changed

noxfile.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
docs_requirements = ("mkdocs", "mkdocs-material", "mkautodoc>=0.1.0")
1010

1111

12-
@nox.session(python=["3.6", "3.7", "3.8", "3.9", "3.10"])
12+
@nox.session(python=["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"])
1313
def test(session):
1414
deps = ["pytest", "pytest-asyncio", "pytest-cov", "trio", "starlette", "flask"]
1515
session.install("--upgrade", *deps)
@@ -30,7 +30,7 @@ def check(session):
3030
session.run("black", "--check", "--diff", "--target-version=py36", *source_files)
3131
session.run("isort", "--check", "--diff", "--project=respx", *source_files)
3232
session.run("flake8", *source_files)
33-
session.run("mypy", "respx")
33+
session.run("mypy")
3434

3535

3636
@nox.session

respx/models.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,30 +44,40 @@ def clone_response(response: httpx.Response, request: httpx.Request) -> httpx.Re
4444

4545
class Call(NamedTuple):
4646
request: httpx.Request
47-
response: Optional[httpx.Response]
47+
optional_response: Optional[httpx.Response]
48+
49+
@property
50+
def response(self) -> httpx.Response:
51+
if self.optional_response is None:
52+
raise ValueError(f"{self!r} has no response")
53+
return self.optional_response
54+
55+
@property
56+
def has_response(self) -> bool:
57+
return self.optional_response is not None
4858

4959

5060
class CallList(list, mock.NonCallableMock):
51-
def __init__(self, *args, name="respx", **kwargs):
52-
super().__init__(*args, **kwargs)
61+
def __init__(self, *args: Sequence[Call], name: Any = "respx") -> None:
62+
super().__init__(*args)
5363
mock.NonCallableMock.__init__(self, name=name)
5464

5565
@property
56-
def called(self) -> bool: # type: ignore
66+
def called(self) -> bool: # type: ignore[override]
5767
return bool(self)
5868

5969
@property
60-
def call_count(self) -> int: # type: ignore
70+
def call_count(self) -> int: # type: ignore[override]
6171
return len(self)
6272

6373
@property
64-
def last(self) -> Optional[Call]:
65-
return self[-1] if self else None
74+
def last(self) -> Call:
75+
return self[-1]
6676

6777
def record(
6878
self, request: httpx.Request, response: Optional[httpx.Response]
6979
) -> Call:
70-
call = Call(request=request, response=response)
80+
call = Call(request=request, optional_response=response)
7181
self.append(call)
7282
return call
7383

@@ -155,7 +165,7 @@ def name(self, name: str) -> None:
155165
raise NotImplementedError("Can't set name on route.")
156166

157167
@property
158-
def pattern(self) -> Optional[Pattern]:
168+
def pattern(self) -> Pattern:
159169
return self._pattern
160170

161171
@pattern.setter
@@ -174,7 +184,9 @@ def return_value(self, return_value: Optional[httpx.Response]) -> None:
174184
self._return_value = return_value
175185

176186
@property
177-
def side_effect(self) -> Optional[SideEffectTypes]:
187+
def side_effect(
188+
self,
189+
) -> Optional[Union[SideEffectTypes, Sequence[SideEffectListTypes]]]:
178190
return self._side_effect
179191

180192
@side_effect.setter
@@ -230,7 +242,9 @@ def mock(
230242
self,
231243
return_value: Optional[httpx.Response] = None,
232244
*,
233-
side_effect: Optional[SideEffectTypes] = None,
245+
side_effect: Optional[
246+
Union[SideEffectTypes, Sequence[SideEffectListTypes]]
247+
] = None,
234248
) -> "Route":
235249
self.return_value = return_value
236250
self.side_effect = side_effect
@@ -430,6 +444,8 @@ def __setitem__(self, i: slice, routes: "RouteList") -> None:
430444
"""
431445
Re-set all routes to given routes.
432446
"""
447+
if (i.start, i.stop, i.step) != (None, None, None):
448+
raise TypeError("Can't slice assign routes")
433449
self._routes = list(routes._routes)
434450
self._names = dict(routes._names)
435451

respx/patterns.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,26 @@ def __init__(self, value: Any, lookup: Optional[Lookup] = None) -> None:
8888
def __iter__(self):
8989
yield self
9090

91+
def __bool__(self):
92+
return True
93+
9194
def __and__(self, other: "Pattern") -> "Pattern":
95+
if not bool(other):
96+
return self
97+
elif not bool(self):
98+
return other
9299
return _And((self, other))
93100

94101
def __or__(self, other: "Pattern") -> "Pattern":
102+
if not bool(other):
103+
return self
104+
elif not bool(self):
105+
return other
95106
return _Or((self, other))
96107

97108
def __invert__(self):
109+
if not bool(self):
110+
return self
98111
return _Invert(self)
99112

100113
def __repr__(self): # pragma: nocover
@@ -159,6 +172,22 @@ def _in(self, value: Any) -> Match:
159172
return Match(value in self.value)
160173

161174

175+
class Noop(Pattern):
176+
def __init__(self) -> None:
177+
super().__init__(None)
178+
179+
def __repr__(self):
180+
return f"<{self.__class__.__name__}>"
181+
182+
def __bool__(self) -> bool:
183+
# Treat this pattern as non-existent, e.g. when filtering or conditioning
184+
return False
185+
186+
def match(self, request: httpx.Request) -> Match:
187+
# If this pattern is part of a combined pattern, always be truthy, i.e. noop
188+
return Match(True)
189+
190+
162191
class PathPattern(Pattern):
163192
path: Optional[str]
164193

@@ -500,7 +529,7 @@ def clean(self, value: Dict) -> bytes:
500529
return data
501530

502531

503-
def M(*patterns: Pattern, **lookups: Any) -> Optional[Pattern]:
532+
def M(*patterns: Pattern, **lookups: Any) -> Pattern:
504533
extras = None
505534

506535
for pattern__lookup, value in lookups.items():
@@ -550,12 +579,10 @@ def get_scheme_port(scheme: Optional[str]) -> Optional[int]:
550579
return {"http": 80, "https": 443}.get(scheme or "")
551580

552581

553-
def combine(
554-
patterns: Sequence[Pattern], op: Callable = operator.and_
555-
) -> Optional[Pattern]:
582+
def combine(patterns: Sequence[Pattern], op: Callable = operator.and_) -> Pattern:
556583
patterns = tuple(filter(None, patterns))
557584
if not patterns:
558-
return None
585+
return Noop()
559586
return reduce(op, patterns)
560587

561588

@@ -598,14 +625,14 @@ def parse_url_patterns(
598625
return bases
599626

600627

601-
def merge_patterns(pattern: Optional[Pattern], **bases: Pattern) -> Optional[Pattern]:
628+
def merge_patterns(pattern: Pattern, **bases: Pattern) -> Pattern:
602629
if not bases:
603630
return pattern
604631

605-
if pattern:
606-
# Flatten pattern
607-
patterns = list(iter(pattern))
632+
# Flatten pattern
633+
patterns: List[Pattern] = list(filter(None, iter(pattern)))
608634

635+
if patterns:
609636
if "host" in (_pattern.key for _pattern in patterns):
610637
# Pattern is "absolute", skip merging
611638
bases = {}

setup.cfg

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ skip_covered = True
3636
show_missing = True
3737

3838
[mypy]
39+
python_version = 3.6
40+
files = respx,tests
41+
pretty = True
42+
3943
no_implicit_reexport = True
4044
no_implicit_optional = True
4145
strict_equality = True
@@ -53,3 +57,12 @@ show_error_codes = True
5357

5458
[mypy-pytest.*]
5559
ignore_missing_imports = True
60+
61+
[mypy-trio.*]
62+
ignore_missing_imports = True
63+
64+
[mypy-flask.*]
65+
ignore_missing_imports = True
66+
67+
[mypy-starlette.*]
68+
ignore_missing_imports = True

tests/test_api.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ async def test_url_match(client, url, pattern):
9999
async def test_invalid_url_pattern():
100100
async with MockRouter() as respx_mock:
101101
with pytest.raises(TypeError):
102-
respx_mock.get(["invalid"])
102+
respx_mock.get(["invalid"]) # type: ignore[arg-type]
103103

104104

105105
@pytest.mark.asyncio
@@ -277,7 +277,10 @@ async def test_raising_content(client):
277277
async with MockRouter() as respx_mock:
278278
url = "https://foo.bar/"
279279
request = respx_mock.get(url)
280-
request.side_effect = httpx.ConnectTimeout("X-P", request=None)
280+
request.side_effect = httpx.ConnectTimeout(
281+
"X-P",
282+
request=None, # type: ignore[arg-type]
283+
)
281284
with pytest.raises(httpx.ConnectTimeout):
282285
await client.get(url)
283286

@@ -293,7 +296,9 @@ async def test_raising_content(client):
293296

294297
assert route.call_count == 2
295298
assert route.calls.last.request is not None
296-
assert route.calls.last.response is None
299+
assert route.calls.last.has_response is False
300+
with pytest.raises(ValueError, match="has no response"):
301+
assert route.calls.last.response
297302

298303

299304
@pytest.mark.asyncio
@@ -356,7 +361,9 @@ def callback(request, name):
356361
assert response.text == "hello lundberg"
357362

358363
with pytest.raises(TypeError):
359-
respx_mock.get("https://ham.spam/").mock(side_effect=lambda req: "invalid")
364+
respx_mock.get("https://ham.spam/").mock(
365+
side_effect=lambda req: "invalid" # type: ignore[arg-type,return-value]
366+
)
360367
await client.get("https://ham.spam/")
361368

362369
with pytest.raises(httpx.NetworkError):
@@ -526,10 +533,10 @@ def test_add():
526533
assert respx.routes["foobar"].called
527534

528535
with pytest.raises(TypeError):
529-
respx.add(route, status_code=418) # pragma: nocover
536+
respx.add(route, status_code=418) # type: ignore[call-arg]
530537

531538
with pytest.raises(ValueError):
532-
respx.add("GET") # pragma: nocover
539+
respx.add("GET") # type: ignore[arg-type]
533540

534541
with pytest.raises(NotImplementedError):
535542
route.name = "spam"
@@ -554,7 +561,7 @@ def test_respond():
554561
route.respond(content={})
555562

556563
with pytest.raises(TypeError, match="content can only be"):
557-
route.respond(content=Exception())
564+
route.respond(content=Exception()) # type: ignore[arg-type]
558565

559566

560567
@pytest.mark.asyncio

tests/test_mock.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -317,25 +317,25 @@ async def test_configured_router_reuse(client):
317317
with router:
318318
route.return_value = httpx.Response(202)
319319
response = await client.get("https://foo/bar/")
320-
assert route.called is True
320+
assert route.called == True # noqa: E712
321321
assert response.status_code == 202
322322
assert router.calls.call_count == 1
323323
assert respx.calls.call_count == 0
324324

325325
assert len(router.routes) == 1
326-
assert route.called is False
326+
assert route.called == False # noqa: E712
327327
assert router.calls.call_count == 0
328328

329329
async with router:
330330
assert router.calls.call_count == 0
331331
response = await client.get("https://foo/bar/")
332-
assert route.called is True
332+
assert route.called == True # noqa: E712
333333
assert response.status_code == 404
334334
assert router.calls.call_count == 1
335335
assert respx.calls.call_count == 0
336336

337337
assert len(router.routes) == 1
338-
assert route.called is False
338+
assert route.called == False # noqa: E712
339339
assert router.calls.call_count == 0
340340
assert respx.calls.call_count == 0
341341

@@ -346,7 +346,7 @@ async def test_router_return_type_misuse():
346346
route = router.get("https://hot.dog/")
347347

348348
with pytest.raises(TypeError):
349-
route.return_value = "not-a-httpx-response"
349+
route.return_value = "not-a-httpx-response" # type: ignore[assignment]
350350

351351

352352
@pytest.mark.asyncio
@@ -396,20 +396,20 @@ async def test_start_stop(client):
396396
try:
397397
respx.start()
398398
response = await client.get(url)
399-
assert request.called is True
399+
assert request.called == True # noqa: E712
400400
assert response.status_code == 202
401401
assert response.text == ""
402402
assert respx.calls.call_count == 1
403403

404404
respx.stop(clear=False, reset=False)
405405
assert len(respx.routes) == 1
406406
assert respx.calls.call_count == 1
407-
assert request.called is True
407+
assert request.called == True # noqa: E712
408408

409409
respx.reset()
410410
assert len(respx.routes) == 1
411411
assert respx.calls.call_count == 0
412-
assert request.called is False
412+
assert request.called == False # noqa: E712
413413

414414
respx.clear()
415415
assert len(respx.routes) == 0
@@ -545,7 +545,7 @@ async def test():
545545

546546
def test_router_using__invalid():
547547
with pytest.raises(ValueError, match="using"):
548-
respx.MockRouter(using=123).using
548+
respx.MockRouter(using=123).using # type: ignore[arg-type]
549549

550550

551551
def test_mocker_subclass():

tests/test_patterns.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
Lookup,
1616
M,
1717
Method,
18+
Noop,
1819
Params,
1920
Path,
2021
Pattern,
@@ -66,6 +67,18 @@ def test_match_context():
6667
assert match.context == {"host": "foo.bar", "slug": "baz"}
6768

6869

70+
def test_noop_pattern():
71+
assert bool(Noop()) is False
72+
assert bool(Noop().match(httpx.Request("GET", "https://example.org"))) is True
73+
assert list(filter(None, [Noop()])) == []
74+
assert repr(Noop()) == "<Noop>"
75+
assert isinstance(~Noop(), Noop)
76+
assert Method("GET") & Noop() == Method("GET")
77+
assert Noop() & Method("GET") == Method("GET")
78+
assert Method("GET") | Noop() == Method("GET")
79+
assert Noop() | Method("GET") == Method("GET")
80+
81+
6982
@pytest.mark.parametrize(
7083
"kwargs,url,expected",
7184
[

tests/test_remote.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def test_remote_pass_through(using, client_lib, call_count): # pragma: nocover
3737
assert response.json()["json"] == {"foo": "bar"}
3838

3939
assert respx_mock.calls.last.request.url == url
40-
assert respx_mock.calls.last.response is None
40+
assert respx_mock.calls.last.has_response is False
4141

4242
assert route.call_count == call_count
4343
assert respx_mock.calls.call_count == call_count

0 commit comments

Comments
 (0)