Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 0.34.0 #172

Merged
merged 11 commits into from
Nov 18, 2024
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