Skip to content

Commit

Permalink
Merge pull request #172 from Colin-b/develop
Browse files Browse the repository at this point in the history
Release 0.34.0
  • Loading branch information
Colin-b authored Nov 18, 2024
2 parents f0e070d + 19195f5 commit c91c327
Show file tree
Hide file tree
Showing 8 changed files with 465 additions and 403 deletions.
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.34.0] - 2024-11-18
### Added
- `is_optional` parameter is now available on responses and callbacks registration. Allowing to add optional responses while keeping other responses as mandatory. Refer to documentation for more details.
- `is_reusable` parameter is now available on responses and callbacks registration. Allowing to add multi-match responses while keeping other responses as single-match. Refer to documentation for more details.

### Fixed
- `httpx_mock.get_request` will now also propose to refine filters if more than one request is found instead of only proposing to switch to `httpx_mock.get_requests`.

## [0.33.0] - 2024-10-28
### Added
- Explicit support for python `3.13`.
Expand Down Expand Up @@ -396,7 +404,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.33.0...HEAD
[Unreleased]: https://github.com/Colin-b/pytest_httpx/compare/v0.34.0...HEAD
[0.34.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.33.0...v0.34.0
[0.33.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.32.0...v0.33.0
[0.32.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.31.2...v0.32.0
[0.31.2]: https://github.com/Colin-b/pytest_httpx/compare/v0.31.1...v0.31.2
Expand Down
43 changes: 39 additions & 4 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-260 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-272 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 @@ -716,9 +716,17 @@ def pytest_collection_modifyitems(session, config, items):

By default, `pytest-httpx` will ensure that every response was requested during test execution.

You can use the `httpx_mock` marker `assert_all_responses_were_requested` option to allow fewer requests than what you registered responses for.
If you want to add an optional response, you can use the `is_optional` parameter when [registering a response](#add-responses) or [a callback](#add-callbacks).

This option can be useful if you add responses using shared fixtures.
```python
def test_fewer_requests_than_expected(httpx_mock):
# Even if this response never received a corresponding request, the test will not fail at teardown
httpx_mock.add_response(is_optional=True)
```

If you don't have control over the response registration process (shared fixtures),
and you want to allow fewer requests than what you registered responses for,
you can use the `httpx_mock` marker `assert_all_responses_were_requested` option.

> [!CAUTION]
> Use this option at your own risk of not spotting regression (requests not sent) in your code base!
Expand All @@ -732,6 +740,18 @@ def test_fewer_requests_than_expected(httpx_mock):
httpx_mock.add_response()
```

Note that the `is_optional` parameter will take precedence over the `assert_all_responses_were_requested` option.
Meaning you can still register a response that will be checked for execution at teardown even if `assert_all_responses_were_requested` was set to `False`.

```python
import pytest

@pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
def test_force_expected_request(httpx_mock):
# Even if the assert_all_responses_were_requested option is set, the test will fail at teardown if this is not matched
httpx_mock.add_response(is_optional=False)
```

#### Allow to not register responses for every request

By default, `pytest-httpx` will ensure that every request that was issued was expected.
Expand All @@ -757,7 +777,22 @@ def test_more_requests_than_expected(httpx_mock):

By default, `pytest-httpx` will ensure that every request that was issued was expected.

You can use the `httpx_mock` marker `can_send_already_matched_responses` option to allow multiple requests to match the same registered response.
If you want to add a response once, while allowing it to match more than once, you can use the `is_reusable` parameter when [registering a response](#add-responses) or [a callback](#add-callbacks).

```python
import httpx

def test_more_requests_than_responses(httpx_mock):
httpx_mock.add_response(is_reusable=True)
with httpx.Client() as client:
client.get("https://test_url")
# Even if only one response was registered, the test will not fail at teardown as this request will also be matched
client.get("https://test_url")
```

If you don't have control over the response registration process (shared fixtures),
and you want to allow multiple requests to match the same registered response,
you can use the `httpx_mock` marker `can_send_already_matched_responses` option.

With this option, in case all matching responses have been sent at least once, the last one (according to the registration order) will be sent.

Expand Down
38 changes: 21 additions & 17 deletions pytest_httpx/_httpx_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ def add_response(
:param match_json: JSON decoded HTTP body identifying the request(s) to match. Must be JSON encodable.
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
:param is_optional: True will mark this response as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False).
:param is_reusable: True will allow re-using this response even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False).
"""

json = copy.deepcopy(json) if json is not None else None
Expand Down Expand Up @@ -111,6 +112,8 @@ def add_callback(
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
:param is_optional: True will mark this callback as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False).
:param is_reusable: True will allow re-using this callback even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False).
"""
self._callbacks.append((_RequestMatcher(self._options, **matchers), callback))

Expand All @@ -130,6 +133,8 @@ def add_exception(self, exception: Exception, **matchers: Any) -> None:
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
:param is_optional: True will mark this exception response as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False).
:param is_reusable: True will allow re-using this exception response even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False).
"""

def exception_callback(request: httpx.Request) -> None:
Expand Down Expand Up @@ -212,7 +217,7 @@ def _explain_that_no_response_was_found(
message += f" amongst:\n{matchers_description}"
# If we could not find a response, but we have already matched responses
# it might be that user is expecting one of those responses to be reused
if already_matched and not self._options.can_send_already_matched_responses:
if any(not matcher.is_reusable for matcher in already_matched):
message += "\n\nIf you wanted to reuse an already matched response instead of registering it again, refer to https://github.com/Colin-b/pytest_httpx/blob/master/README.md#allow-to-register-a-response-for-more-than-one-request"

return message
Expand Down Expand Up @@ -245,7 +250,7 @@ def _get_callback(
return callback

# Or the last registered (if it can be reused)
if self._options.can_send_already_matched_responses:
if matcher.is_reusable:
matcher.nb_calls += 1
return callback

Expand Down Expand Up @@ -295,7 +300,7 @@ def get_request(self, **matchers: Any) -> Optional[httpx.Request]:
requests = self.get_requests(**matchers)
assert (
len(requests) <= 1
), f"More than one request ({len(requests)}) matched, use get_requests instead."
), f"More than one request ({len(requests)}) matched, use get_requests instead or refine your filters."
return requests[0] if requests else None

def reset(self) -> None:
Expand All @@ -304,20 +309,19 @@ def reset(self) -> None:
self._requests_not_matched.clear()

def _assert_options(self) -> None:
if self._options.assert_all_responses_were_requested:
callbacks_not_executed = [
matcher for matcher, _ in self._callbacks if not matcher.nb_calls
]
matchers_description = "\n".join(
[f"- {matcher}" for matcher in callbacks_not_executed]
)
callbacks_not_executed = [
matcher for matcher, _ in self._callbacks if matcher.should_have_matched()
]
matchers_description = "\n".join(
[f"- {matcher}" for matcher in callbacks_not_executed]
)

assert not callbacks_not_executed, (
"The following responses are mocked but not requested:\n"
f"{matchers_description}\n"
"\n"
"If this is on purpose, refer to https://github.com/Colin-b/pytest_httpx/blob/master/README.md#allow-to-register-more-responses-than-what-will-be-requested"
)
assert not callbacks_not_executed, (
"The following responses are mocked but not requested:\n"
f"{matchers_description}\n"
"\n"
"If this is on purpose, refer to https://github.com/Colin-b/pytest_httpx/blob/master/README.md#allow-to-register-more-responses-than-what-will-be-requested"
)

if self._options.assert_all_requests_were_expected:
requests_description = "\n".join(
Expand Down
10 changes: 9 additions & 1 deletion pytest_httpx/_request_matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ def __init__(
match_data: Optional[dict[str, Any]] = None,
match_files: Optional[Any] = None,
match_extensions: Optional[dict[str, Any]] = None,
is_optional: Optional[bool] = None,
is_reusable: Optional[bool] = None,
):
self._options = options
self.nb_calls = 0
Expand All @@ -55,6 +57,8 @@ def __init__(
else proxy_url
)
self.extensions = match_extensions
self.is_optional = not options.assert_all_responses_were_requested if is_optional is None else is_optional
self.is_reusable = options.can_send_already_matched_responses if is_reusable is None else is_reusable
if self._is_matching_body_more_than_one_way():
raise ValueError(
"Only one way of matching against the body can be provided. "
Expand Down Expand Up @@ -177,8 +181,12 @@ def _extensions_match(self, request: httpx.Request) -> bool:
for extension_name, extension_value in self.extensions.items()
)

def should_have_matched(self) -> bool:
"""Return True if the matcher did not serve its purpose."""
return not self.is_optional and not self.nb_calls

def __str__(self) -> str:
if self._options.can_send_already_matched_responses:
if self.is_reusable:
matcher_description = f"Match {self.method or 'every'} request"
else:
matcher_description = "Already matched" if self.nb_calls else "Match"
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.33.0"
__version__ = "0.34.0"
Loading

0 comments on commit c91c327

Please sign in to comment.