Skip to content

Commit b11adf2

Browse files
authored
Merge branch 'master' into drop_3_8_support
2 parents 93e70bc + 2eabdb8 commit b11adf2

14 files changed

+255
-43
lines changed

Makefile

+20-6
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,31 @@
11
PYTHON_VERSION ?= 3.9
22

3-
dist: clean-dist venv
3+
dist: clean-dist setup
44
. venv/bin/activate && python3 -m build .
55

6-
setup: venv
6+
.PHONY: setup
7+
setup: venv/setup.txt
78

8-
venv: dev-requirements.txt requirements.txt
9+
venv:
910
virtualenv venv --python=${PYTHON_VERSION}
11+
12+
venv/setup.txt: venv dev-requirements.txt requirements.txt
1013
. venv/bin/activate && \
1114
pip3 install --upgrade pip && \
1215
pip3 install \
1316
--requirement dev-requirements.txt \
1417
--requirement requirements.txt
18+
touch venv/setup.txt
1519

1620
.PHONY: test
17-
test: venv
18-
@ . venv/bin/activate && PYTHONPATH=src/ pytest -rsx tests src --cov ./src/requtests --no-cov-on-fail --cov-report term-missing --doctest-modules --doctest-continue-on-failure
21+
test: setup
22+
@ . venv/bin/activate && PYTHONPATH=src/ pytest -vv -rsx tests src --cov ./src/requtests --no-cov-on-fail --cov-report term-missing --doctest-modules --doctest-continue-on-failure
1923
@ . venv/bin/activate && flake8 src --exclude '#*,~*,.#*'
2024
@ . venv/bin/activate && black --check src tests
25+
@ . venv/bin/activate && mypy src
2126

2227
.PHONY: test-focus
23-
test-focus: venv/setup.txt
28+
test-focus: setup
2429
@ . venv/bin/activate && PYTHONPATH=src/ pytest -vv -m focus -rsx tests src --cov ./src/requtests --no-cov-on-fail --cov-report term-missing --doctest-modules --doctest-continue-on-failure
2530
@ . venv/bin/activate && flake8 src --exclude '#*,~*,.#*'
2631
@ . venv/bin/activate && black --check src tests
@@ -34,3 +39,12 @@ clean-dist:
3439
rm -rf build
3540
rm -rf src/requtests.egg-info
3641
rm -rf dist
42+
43+
.PHONY: release
44+
release: test dist
45+
. venv/bin/activate && twine upload dist/*
46+
47+
.PHONY: test-release
48+
test-release: test dist
49+
. venv/bin/activate && twine upload -r testpypi dist/*
50+

README.md

+27-2
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ The faked adapter can be mounted using the standard `mount` method on an instanc
2323
#### Example
2424

2525
```python3
26-
from requtests import FakeAdapter, fake_response
2726
from requests import Session
27+
from requtests import FakeAdapter, fake_response
2828

2929

3030
class Client:
@@ -96,9 +96,34 @@ def test_login():
9696
password = "my-password"
9797
request_func = fake_request_with_response(json={"token": "my-login-token"})
9898
assert login(username, password, request_func=request_func) == "my-login-token"
99-
10099
```
101100

102101
### `fake_response`
103102

104103
Returns a `requests.Response` object with either the return value of its `json()` method set to a python data structure or its `text` property set.
104+
105+
### `ParsedRequest`
106+
107+
A test helper object wrapping a `PreparedRequest` object to make it easier to write assertions. In addition to wrapping the `PreparedRequest`'s `body`, `headers`, `method`, and `url` properties, it also provides the following convenience properties.
108+
109+
* `endpoint` - the URL without any query parameters.
110+
* `query` - any query parameters, parsed and decoded.
111+
* `json` - the body parsed as JSON.
112+
* `text` - the body decoded as a string.
113+
114+
#### Example
115+
116+
```python3
117+
from requtests import ParsedRequest
118+
119+
def _create_user_assertions(prepared_request, **kwargs):
120+
parsed_request = ParsedRequest(prepared_request)
121+
assert parsed_request.method == "POST"
122+
assert parsed_request.url == "https://example.com/users?action=create"
123+
assert parsed_request.endpoint == "https://example.com/users"
124+
assert parsed_request.query == {"action": "create"}
125+
assert parsed_request.headers["Authorization"] == "Bearer token"
126+
assert parsed_request.body == b'{"username": "my_username"}'
127+
assert parsed_request.json == {"username": "my_username"}
128+
assert parsed_request.text == '{"username": "my_username"}'
129+
```

dev-requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
black
22
build
33
flake8
4+
mypy
45
pytest
56
pytest-clarity
67
pytest-cov
78
twine
9+
types-requests

src/requtests/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
fake_request_with_response,
1212
)
1313
from .fake_response import fake_response
14+
from .parsed_request import ParsedRequest
1415

1516
__all__ = [
1617
"FakeAdapter",
@@ -24,5 +25,6 @@
2425
"fake_request",
2526
"fake_request_with_response",
2627
"fake_response",
28+
"ParsedRequest",
2729
]
2830
__version__ = "2.0.0"

src/requtests/fake_adapter.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from itertools import cycle
2+
from requests import Response
23
from requests.adapters import BaseAdapter
4+
from .protocols import OptionalAssertions
35

46

57
class FakeAdapter(BaseAdapter):
6-
def __init__(self, *responses, assertions=None):
8+
def __init__(self, *responses: Response, assertions: OptionalAssertions = None):
79
super().__init__()
810
self.closed = 0
911
self.responses = _to_generator(responses)

src/requtests/fake_request.py

+16-12
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,54 @@
11
from functools import partial
22
from requests import Session
3-
from requtests.fake_adapter import FakeAdapter
4-
from requtests.fake_response import fake_response
3+
from .fake_adapter import FakeAdapter
4+
from .fake_response import fake_response
5+
from .protocols import OptionalAssertions, Responder
56

67

7-
def fake_delete(*responses, assertions=None):
8+
def fake_delete(*responses, assertions: OptionalAssertions = None) -> Responder:
89
return partial(fake_request(*responses, assertions=assertions), "delete")
910

1011

11-
def fake_get(*responses, assertions=None):
12+
def fake_get(*responses, assertions: OptionalAssertions = None) -> Responder:
1213
return partial(fake_request(*responses, assertions=assertions), "get")
1314

1415

15-
def fake_head(*responses, assertions=None):
16+
def fake_head(*responses, assertions: OptionalAssertions = None) -> Responder:
1617
return partial(fake_request(*responses, assertions=assertions), "head")
1718

1819

19-
def fake_options(*responses, assertions=None):
20+
def fake_options(*responses, assertions: OptionalAssertions = None) -> Responder:
2021
return partial(fake_request(*responses, assertions=assertions), "options")
2122

2223

23-
def fake_patch(*responses, assertions=None):
24+
def fake_patch(*responses, assertions: OptionalAssertions = None) -> Responder:
2425
return partial(fake_request(*responses, assertions=assertions), "patch")
2526

2627

27-
def fake_post(*responses, assertions=None):
28+
def fake_post(*responses, assertions: OptionalAssertions = None) -> Responder:
2829
return partial(fake_request(*responses, assertions=assertions), "post")
2930

3031

31-
def fake_put(*responses, assertions=None):
32+
def fake_put(*responses, assertions: OptionalAssertions = None) -> Responder:
3233
return partial(fake_request(*responses, assertions=assertions), "put")
3334

3435

35-
def fake_request_with_response(assertions=None, **response_config):
36+
def fake_request_with_response(
37+
assertions: OptionalAssertions = None,
38+
**response_config,
39+
) -> Responder:
3640
"""
3741
Creates a request function that returns a response given the response_config.
3842
"""
3943
return fake_request(fake_response(**response_config), assertions=assertions)
4044

4145

42-
def fake_request(*responses, assertions=None):
46+
def fake_request(*responses, assertions: OptionalAssertions = None) -> Responder:
4347
"""
4448
Creates a request function that returns the supplied responses, one at a time.
4549
Making a new request after the last response has been returned results in a StopIteration error.
4650
"""
4751
adapter = FakeAdapter(*responses, assertions=assertions)
4852
session = Session()
49-
session.get_adapter = lambda url: adapter
53+
setattr(session, "get_adapter", lambda url: adapter)
5054
return session.request

src/requtests/fake_response.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from json import dumps as to_json
22
from requests.models import Response
3+
from requests.structures import CaseInsensitiveDict
34

45

56
def fake_response(
@@ -8,18 +9,18 @@ def fake_response(
89
status_code=200,
910
text=None,
1011
url=None,
11-
headers={},
12-
):
12+
headers=None,
13+
) -> Response:
1314
"""
14-
Returns a populated instance of Response.
15+
Returns a populated instance of requests.models.Response.
1516
"""
1617

1718
response = Response()
1819
response._content = _content(json, text)
1920
response.reason = reason
2021
response.status_code = status_code
2122
response.url = url
22-
response.headers = headers
23+
response.headers = CaseInsensitiveDict(**(headers or {}))
2324
return response
2425

2526

src/requtests/parsed_request.py

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import json
2+
from json import JSONDecodeError
3+
from typing import Any, Dict, List, Optional, Union
4+
from urllib.parse import parse_qs, urlparse
5+
6+
7+
class CannotParseBodyAsJSON(RuntimeError):
8+
def __init__(self, error):
9+
super().__init__(error)
10+
self.error = error
11+
12+
13+
class ParsedRequest:
14+
def __init__(self, prepared_request):
15+
self.prepared_request = prepared_request
16+
self._parsed_url = urlparse(self.prepared_request.url)
17+
self._parsed_query = parse_qs(self._parsed_url.query)
18+
19+
def __repr__(self):
20+
return f"<{self.__class__.__name__} [{self.method}]>"
21+
22+
@property
23+
def body(self) -> Optional[bytes]:
24+
return self.prepared_request.body
25+
26+
@property
27+
def endpoint(self) -> str:
28+
return f"{self._parsed_url.scheme}://{self._parsed_url.netloc}{self._parsed_url.path}"
29+
30+
@property
31+
def headers(self) -> Dict[str, str]:
32+
return self.prepared_request.headers
33+
34+
@property
35+
def json(self) -> Any:
36+
"""
37+
The body of the prepared request, parsed as JSON.
38+
39+
Raises a CannotParseBodyAsJSON error if the body is not valid JSON.
40+
"""
41+
try:
42+
return json.loads(self.prepared_request.body)
43+
except (TypeError, JSONDecodeError) as e:
44+
raise CannotParseBodyAsJSON(e)
45+
46+
@property
47+
def method(self) -> str:
48+
return self.prepared_request.method
49+
50+
@property
51+
def query(self) -> Dict[str, Any]:
52+
return {key: _delist(value) for key, value in self._parsed_query.items()}
53+
54+
@property
55+
def text(self) -> str:
56+
"""
57+
The body of the prepared request, decoded as Unicode.
58+
"""
59+
return self.prepared_request.body.decode() if self.prepared_request.body else ""
60+
61+
@property
62+
def url(self) -> str:
63+
return self.prepared_request.url
64+
65+
66+
def _delist(value: List[Any]) -> Union[Any, List[Any]]:
67+
"""
68+
Extracts the value from a list with a single value, leaving other lists unmodifed.
69+
"""
70+
return value[0] if len(value) == 1 else value

src/requtests/protocols.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import Any, Callable, List, Optional, Protocol, Union
2+
from requests.models import PreparedRequest, Response
3+
4+
5+
class AssertionFunction(Protocol):
6+
def __call__(self, prepared_request: PreparedRequest, **kwargs) -> Any:
7+
"""
8+
An assertion function is expected to raise an error if any of its assertions fail.
9+
"""
10+
pass
11+
12+
13+
Assertions = Union[AssertionFunction, List[AssertionFunction]]
14+
OptionalAssertions = Optional[Assertions]
15+
Responder = Callable[..., Response]

tests/fake_adapter_test.py

+8-10
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def test_fake_adapter_with_assert_step():
2828
response,
2929
assertions=assert_prepared_request(url=TEST_URL, body=TEST_DATA),
3030
)
31-
assert adapter.send(build_request(url=TEST_URL, body=TEST_DATA)) == response
31+
assert adapter.send(build_request(url=TEST_URL, data=TEST_DATA)) == response
3232

3333

3434
def test_fake_adapter_with_failing_assert_step():
@@ -37,8 +37,8 @@ def test_fake_adapter_with_failing_assert_step():
3737
response,
3838
assertions=assert_prepared_request(url=TEST_URL, body=TEST_DATA),
3939
)
40-
with pytest.raises(AssertionError, match="assert 'unexpected data' == 'some data'"):
41-
adapter.send(build_request(url=TEST_URL, body="unexpected data")) == response
40+
with pytest.raises(AssertionError, match="some data"):
41+
adapter.send(build_request(url=TEST_URL, data="unexpected data")) == response
4242

4343

4444
def test_fake_adapter_with_multiple_responses():
@@ -49,26 +49,24 @@ def test_fake_adapter_with_multiple_responses():
4949
response_2,
5050
assertions=assert_prepared_request(url=TEST_URL, body=TEST_DATA),
5151
)
52-
request = build_request(url=TEST_URL, body=TEST_DATA)
52+
request = build_request(url=TEST_URL, data=TEST_DATA)
5353
assert adapter.send(request) is response_1
5454
assert adapter.send(request) is response_2
5555

5656

5757
def test_fake_adapter_with_multiple_responses_and_assertions():
58-
data_1 = TEST_DATA
59-
data_2 = "some more data"
6058
response_1 = fake_response(status_code=429)
6159
response_2 = fake_response()
6260
adapter = FakeAdapter(
6361
response_1,
6462
response_2,
6563
assertions=[
66-
assert_prepared_request(url=TEST_URL, body=data_1),
67-
assert_prepared_request(url=TEST_URL, body=data_2),
64+
assert_prepared_request(url=TEST_URL, body=TEST_DATA),
65+
assert_prepared_request(url=TEST_URL, body=b'{"even": "more data"}'),
6866
],
6967
)
70-
request_1 = build_request(url=TEST_URL, body=data_1)
71-
request_2 = build_request(url=TEST_URL, body=data_2)
68+
request_1 = build_request(url=TEST_URL, data=TEST_DATA)
69+
request_2 = build_request(url=TEST_URL, json={"even": "more data"})
7270
assert adapter.send(request_1) is response_1
7371
assert adapter.send(request_2) is response_2
7472

tests/fake_request_test.py

-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import pytest
21
from requests.models import PreparedRequest
3-
42
from requtests import fake_request, fake_request_with_response, fake_response
53
from tests.test_utils import assert_response
64

tests/fake_response_test.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from requtests import fake_response
2-
from .test_utils import assert_response
2+
from tests.test_utils import assert_response
33

44

55
def test_fake_response_with_json_data():

0 commit comments

Comments
 (0)