Skip to content

Commit

Permalink
Merge pull request #108 from Colin-b/develop
Browse files Browse the repository at this point in the history
Release 0.24.0
  • Loading branch information
Colin-b authored Sep 4, 2023
2 parents 71b078e + 7594dd8 commit fa7920d
Show file tree
Hide file tree
Showing 6 changed files with 607 additions and 166 deletions.
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.24.0] - 2023-09-04
### Added
- Added `match_json` parameter which allows matching on JSON decoded body (matching against python representation instead of bytes).

### Changed
- Even if it was never documented as a feature, the `match_headers` parameter was not considering header names case when matching.
- As this might have been considered a feature by some users, the fact that `match_headers` will now respect casing is documented as a breaking change.

### Fixed
- Matching on headers does not ignore name case anymore, the name must now be cased as sent (as some servers might expect a specific case).
- Error message in case a request does not match will now include request headers with mismatching name case as well.
- Error message in case a request does not match will now include request headers when not provided as lower-cased to `match_headers`.
- Add `:Any` type hint to `**matchers` function arguments to satisfy strict type checking mode in [`pyright`](https://microsoft.github.io/pyright/#/).

## [0.23.1] - 2023-08-02
### Fixed
- Version `0.23.0` introduced a regression removing the support for mutating json content provided in `httpx_mock.add_response`.
Expand Down Expand Up @@ -262,7 +276,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- First release, should be considered as unstable for now as design might change.

[Unreleased]: https://github.com/Colin-b/pytest_httpx/compare/v0.23.1...HEAD
[Unreleased]: https://github.com/Colin-b/pytest_httpx/compare/v0.24.0...HEAD
[0.24.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.23.1...v0.24.0
[0.23.1]: https://github.com/Colin-b/pytest_httpx/compare/v0.23.0...v0.23.1
[0.23.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.22.0...v0.23.0
[0.22.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.21.3...v0.22.0
Expand Down
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<a href="https://github.com/Colin-b/pytest_httpx/actions"><img alt="Build status" src="https://github.com/Colin-b/pytest_httpx/workflows/Release/badge.svg"></a>
<a href="https://github.com/Colin-b/pytest_httpx/actions"><img alt="Coverage" src="https://img.shields.io/badge/coverage-100%25-brightgreen"></a>
<a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>
<a href="https://github.com/Colin-b/pytest_httpx/actions"><img alt="Number of tests" src="https://img.shields.io/badge/tests-171 passed-blue"></a>
<a href="https://github.com/Colin-b/pytest_httpx/actions"><img alt="Number of tests" src="https://img.shields.io/badge/tests-192 passed-blue"></a>
<a href="https://pypi.org/project/pytest-httpx/"><img alt="Number of downloads" src="https://img.shields.io/pypi/dm/pytest_httpx"></a>
</p>

Expand Down Expand Up @@ -158,7 +158,7 @@ from pytest_httpx import HTTPXMock


def test_headers_matching(httpx_mock: HTTPXMock):
httpx_mock.add_response(match_headers={'user-agent': 'python-httpx/0.23.0'})
httpx_mock.add_response(match_headers={'User-Agent': 'python-httpx/0.24.1'})

with httpx.Client() as client:
response = client.get("https://test_url")
Expand All @@ -182,6 +182,33 @@ def test_content_matching(httpx_mock: HTTPXMock):
response = client.post("https://test_url", content=b"This is the body")
```

##### Matching on HTTP JSON body

Use `match_json` parameter to specify the JSON decoded HTTP body to reply to.

Matching is performed on equality. You can however use `unittest.mock.ANY` to do partial matching.

```python
import httpx
from pytest_httpx import HTTPXMock
from unittest.mock import ANY

def test_json_matching(httpx_mock: HTTPXMock):
httpx_mock.add_response(match_json={"a": "json", "b": 2})

with httpx.Client() as client:
response = client.post("https://test_url", json={"a": "json", "b": 2})


def test_partial_json_matching(httpx_mock: HTTPXMock):
httpx_mock.add_response(match_json={"a": "json", "b": ANY})

with httpx.Client() as client:
response = client.post("https://test_url", json={"a": "json", "b": 2})
```
Note that `match_content` cannot be provided if `match_json` is also provided.

### Add JSON response

Use `json` parameter to add a JSON response using python values.
Expand Down Expand Up @@ -515,7 +542,7 @@ def test_timeout(httpx_mock: HTTPXMock):
## Check sent requests

The best way to ensure the content of your requests is still to use the `match_headers` and / or `match_content` parameters when adding a response.
In the same spirit, ensuring that no request was issued does not necessarily requires any code.
In the same spirit, ensuring that no request was issued does not necessarily require any code.

In any case, you always have the ability to retrieve the requests that were issued.

Expand Down
78 changes: 63 additions & 15 deletions pytest_httpx/_httpx_mock.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import copy
import inspect
import json
import re
from typing import List, Union, Optional, Callable, Tuple, Pattern, Any, Dict, Awaitable

Expand All @@ -15,12 +16,18 @@ def __init__(
method: Optional[str] = None,
match_headers: Optional[Dict[str, Any]] = None,
match_content: Optional[bytes] = None,
match_json: Optional[Any] = None,
):
self.nb_calls = 0
self.url = httpx.URL(url) if url and isinstance(url, str) else url
self.method = method.upper() if method else method
self.headers = match_headers
if match_content is not None and match_json is not None:
raise ValueError(
"Only one way of matching against the body can be provided. If you want to match against the JSON decoded representation, use match_json. Otherwise, use match_content."
)
self.content = match_content
self.json = match_json

def match(self, request: httpx.Request) -> bool:
return (
Expand Down Expand Up @@ -57,16 +64,31 @@ def _headers_match(self, request: httpx.Request) -> bool:
if not self.headers:
return True

encoding = request.headers.encoding
request_headers = {}
# Can be cleaned based on the outcome of https://github.com/encode/httpx/discussions/2841
for raw_name, raw_value in request.headers.raw:
if raw_name in request_headers:
request_headers[raw_name] += b", " + raw_value
else:
request_headers[raw_name] = raw_value

return all(
request.headers.get(header_name) == header_value
request_headers.get(header_name.encode(encoding))
== header_value.encode(encoding)
for header_name, header_value in self.headers.items()
)

def _content_match(self, request: httpx.Request) -> bool:
if self.content is None:
if self.content is None and self.json is None:
return True

return request.read() == self.content
if self.content is not None:
return request.read() == self.content
try:
# httpx._content.encode_json hard codes utf-8 encoding.
return json.loads(request.read().decode("utf-8")) == self.json
except json.decoder.JSONDecodeError:
return False

def __str__(self) -> str:
matcher_description = f"Match {self.method or 'all'} requests"
Expand All @@ -76,8 +98,12 @@ def __str__(self) -> str:
matcher_description += f" with {self.headers} headers"
if self.content is not None:
matcher_description += f" and {self.content} body"
elif self.json is not None:
matcher_description += f" and {self.json} json body"
elif self.content is not None:
matcher_description += f" with {self.content} body"
elif self.json is not None:
matcher_description += f" with {self.json} json body"
return matcher_description


Expand All @@ -100,13 +126,13 @@ def add_response(
self,
status_code: int = 200,
http_version: str = "HTTP/1.1",
headers: _httpx_internals.HeaderTypes = None,
headers: Optional[_httpx_internals.HeaderTypes] = None,
content: Optional[bytes] = None,
text: Optional[str] = None,
html: Optional[str] = None,
stream: Any = None,
json: Any = None,
**matchers,
**matchers: Any,
) -> None:
"""
Mock the response that will be sent if a request match.
Expand All @@ -124,6 +150,7 @@ def add_response(
:param method: HTTP method identifying the request(s) to match.
:param match_headers: HTTP headers identifying the request(s) to match. Must be a dictionary.
:param match_content: Full HTTP body identifying the request(s) to match. Must be bytes.
:param match_json: JSON decoded HTTP body identifying the request(s) to match. Must be JSON encodable.
"""

json = copy.deepcopy(json) if json is not None else None
Expand All @@ -148,7 +175,7 @@ def add_callback(
[httpx.Request],
Union[Optional[httpx.Response], Awaitable[Optional[httpx.Response]]],
],
**matchers,
**matchers: Any,
) -> None:
"""
Mock the action that will take place if a request match.
Expand All @@ -160,10 +187,11 @@ def add_callback(
:param method: HTTP method identifying the request(s) to match.
:param match_headers: HTTP headers identifying the request(s) to match. Must be a dictionary.
:param match_content: Full HTTP body identifying the request(s) to match. Must be bytes.
:param match_json: JSON decoded HTTP body identifying the request(s) to match. Must be JSON encodable.
"""
self._callbacks.append((_RequestMatcher(**matchers), callback))

def add_exception(self, exception: Exception, **matchers) -> None:
def add_exception(self, exception: Exception, **matchers: Any) -> None:
"""
Raise an exception if a request match.
Expand All @@ -173,6 +201,7 @@ def add_exception(self, exception: Exception, **matchers) -> None:
:param method: HTTP method identifying the request(s) to match.
:param match_headers: HTTP headers identifying the request(s) to match. Must be a dictionary.
:param match_content: Full HTTP body identifying the request(s) to match. Must be bytes.
:param match_json: JSON decoded HTTP body identifying the request(s) to match. Must be JSON encodable.
"""

def exception_callback(request: httpx.Request) -> None:
Expand Down Expand Up @@ -220,19 +249,36 @@ async def _handle_async_request(

def _explain_that_no_response_was_found(self, request: httpx.Request) -> str:
matchers = [matcher for matcher, _ in self._callbacks]
headers_encoding = request.headers.encoding
expect_headers = set(
[
header
# httpx uses lower cased header names as internal key
header.lower().encode(headers_encoding)
for matcher in matchers
if matcher.headers
for header in matcher.headers
]
)
expect_body = any([matcher.content is not None for matcher in matchers])
expect_body = any(
[
matcher.content is not None or matcher.json is not None
for matcher in matchers
]
)

request_description = f"{request.method} request on {request.url}"
if expect_headers:
request_description += f" with {dict({name: value for name, value in request.headers.items() if name in expect_headers})} headers"
present_headers = {}
# Can be cleaned based on the outcome of https://github.com/encode/httpx/discussions/2841
for name, lower_name, value in request.headers._list:
if lower_name in expect_headers:
name = name.decode(headers_encoding)
if name in present_headers:
present_headers[name] += f", {value.decode(headers_encoding)}"
else:
present_headers[name] = value.decode(headers_encoding)

request_description += f" with {present_headers} headers"
if expect_body:
request_description += f" and {request.read()} body"
elif expect_body:
Expand Down Expand Up @@ -275,28 +321,30 @@ def _get_callback(
matcher.nb_calls += 1
return callback

def get_requests(self, **matchers) -> List[httpx.Request]:
def get_requests(self, **matchers: Any) -> List[httpx.Request]:
"""
Return all requests sent that match (empty list if no requests were matched).
:param url: Full URL identifying the requests to retrieve.
Can be a str, a re.Pattern instance or a httpx.URL instance.
:param method: HTTP method identifying the requests to retrieve. Must be a upper cased string value.
:param method: HTTP method identifying the requests to retrieve. Must be an upper-cased string value.
:param match_headers: HTTP headers identifying the requests to retrieve. Must be a dictionary.
:param match_content: Full HTTP body identifying the requests to retrieve. Must be bytes.
:param match_json: JSON decoded HTTP body identifying the requests to retrieve. Must be JSON encodable.
"""
matcher = _RequestMatcher(**matchers)
return [request for request in self._requests if matcher.match(request)]

def get_request(self, **matchers) -> Optional[httpx.Request]:
def get_request(self, **matchers: Any) -> Optional[httpx.Request]:
"""
Return the single request that match (or None).
:param url: Full URL identifying the request to retrieve.
Can be a str, a re.Pattern instance or a httpx.URL instance.
:param method: HTTP method identifying the request to retrieve. Must be a upper cased string value.
:param method: HTTP method identifying the request to retrieve. Must be an upper-cased string value.
:param match_headers: HTTP headers identifying the request to retrieve. Must be a dictionary.
:param match_content: Full HTTP body identifying the request to retrieve. Must be bytes.
:param match_json: JSON decoded HTTP body identifying the request to retrieve. Must be JSON encodable.
:raises AssertionError: in case more than one request match.
"""
requests = self.get_requests(**matchers)
Expand Down
2 changes: 1 addition & 1 deletion pytest_httpx/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
# Major should be incremented in case there is a breaking change. (eg: 2.5.8 -> 3.0.0)
# Minor should be incremented in case there is an enhancement. (eg: 2.5.8 -> 2.6.0)
# Patch should be incremented in case there is a bug fix. (eg: 2.5.8 -> 2.5.9)
__version__ = "0.23.1"
__version__ = "0.24.0"
Loading

0 comments on commit fa7920d

Please sign in to comment.