Skip to content

Commit

Permalink
Merge pull request #69 from Colin-b/develop
Browse files Browse the repository at this point in the history
Release 0.18.0
  • Loading branch information
Colin-b authored Jan 17, 2022
2 parents 98b6201 + a0dd566 commit 6890383
Show file tree
Hide file tree
Showing 9 changed files with 122 additions and 202 deletions.
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.18.0] - 2022-01-17
### Fixed
- Callback are now executed as expected when there is a matching already sent response.

### Changed
- Registration order is now looking at responses and callbacks. Prior to this version, registration order was looking at responses before callbacks.

### Removed
- `httpx_mock.add_response` `data`, `files` and `boundary` parameters have been removed. It was deprecated since `0.17.0`. Refer to this version changelog entry for more details on how to update your code.

## [0.17.3] - 2021-12-27
### Fixed
- A callback can now raise an exception again (regression in mypy check since [0.16.0]).
Expand Down Expand Up @@ -195,7 +205,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.17.3...HEAD
[Unreleased]: https://github.com/Colin-b/pytest_httpx/compare/v0.18.0...HEAD
[0.18.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.17.3...v0.18.0
[0.17.3]: https://github.com/Colin-b/pytest_httpx/compare/v0.17.2...v0.17.3
[0.17.2]: https://github.com/Colin-b/pytest_httpx/compare/v0.17.1...v0.17.2
[0.17.1]: https://github.com/Colin-b/pytest_httpx/compare/v0.17.0...v0.17.1
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2021 Colin Bounouar
Copyright (c) 2022 Colin Bounouar

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
38 changes: 3 additions & 35 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-151 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-153 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 @@ -419,6 +419,8 @@ def assert_all_responses_were_requested() -> bool:
return False
```

Note that callbacks are considered as responses, and thus are [selected the same way](#how-response-is-selected).

### Dynamic responses

Callback should return a `httpx.Response`.
Expand Down Expand Up @@ -478,40 +480,6 @@ def test_timeout(httpx_mock: HTTPXMock):

```

### How callback is selected

In case more than one callback match request, the first one not yet executed (according to the registration order) will be executed.

In case all matching callbacks have been executed, the last one (according to the registration order) will be executed.

You can add criteria so that callback will be sent only in case of a more specific matching.

#### Matching on URL

`url` parameter can either be a string, a python [`re.Pattern`](https://docs.python.org/3/library/re.html) instance or a [`httpx.URL`](https://www.python-httpx.org/api/#url) instance.

Matching is performed on the full URL, query parameters included.

#### Matching on HTTP method

Use `method` parameter to specify the HTTP method (POST, PUT, DELETE, PATCH, HEAD) executing the callback.

`method` parameter must be a string. It will be upper-cased, so it can be provided lower cased.

Matching is performed on equality.

#### Matching on HTTP headers

Use `match_headers` parameter to specify the HTTP headers executing the callback.

Matching is performed on equality for each provided header.

#### Matching on HTTP body

Use `match_content` parameter to specify the full HTTP body executing the callback.

Matching is performed on equality.

## 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.
Expand Down
16 changes: 0 additions & 16 deletions pytest_httpx/_httpx_internals.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,9 @@
Sequence,
Tuple,
Iterable,
Optional,
AsyncIterator,
Iterator,
Any,
)
import warnings

import httpx

Expand Down Expand Up @@ -39,16 +36,3 @@ async def __aiter__(self) -> AsyncIterator[bytes]:

AsyncIteratorByteStream.__init__(self, stream=Stream())
IteratorByteStream.__init__(self, stream=Stream())


def multipart_stream(
data: dict, files: Any, boundary: Optional[bytes]
) -> Union[httpx.AsyncByteStream, httpx.SyncByteStream]:
warnings.warn(
"data, files and boundary parameters will be removed in a future version. Use stream parameter with an instance of httpx._multipart.MultipartStream instead.",
DeprecationWarning,
)
# import is performed at runtime when needed to reduce impact of internal changes in httpx
from httpx._multipart import MultipartStream

return MultipartStream(data=data or {}, files=files, boundary=boundary)
71 changes: 14 additions & 57 deletions pytest_httpx/_httpx_mock.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import re
from typing import List, Union, Optional, Callable, Tuple, Pattern, Any, Dict
from urllib.parse import parse_qs
import warnings

import httpx

Expand Down Expand Up @@ -87,7 +86,6 @@ def __str__(self) -> str:
class HTTPXMock:
def __init__(self) -> None:
self._requests: List[httpx.Request] = []
self._responses: List[Tuple[_RequestMatcher, httpx.Response]] = []
self._callbacks: List[
Tuple[
_RequestMatcher,
Expand All @@ -104,10 +102,7 @@ def add_response(
text: Optional[str] = None,
html: Optional[str] = None,
stream: Any = None,
data: dict = None,
files: Any = None,
json: Any = None,
boundary: bytes = None,
**matchers,
) -> None:
"""
Expand All @@ -120,10 +115,7 @@ def add_response(
:param text: HTTP body of the response (as string).
:param html: HTTP body of the response (as HTML string content).
:param stream: HTTP body of the response (as httpx.SyncByteStream or httpx.AsyncByteStream) as stream content.
:param data: HTTP multipart body of the response (as a dictionary) if files is provided.
:param files: Multipart files.
:param json: HTTP body of the response (if JSON should be used as content type) if data is not provided.
:param boundary: Multipart boundary if files is provided.
:param url: Full URL identifying the request(s) to match.
Can be a str, a re.Pattern instance or a httpx.URL instance.
:param method: HTTP method identifying the request(s) to match.
Expand All @@ -138,13 +130,9 @@ def add_response(
content=content,
text=text,
html=html,
stream=_httpx_internals.multipart_stream(
data=data, files=files, boundary=boundary
)
if files
else stream,
stream=stream,
)
self._responses.append((_RequestMatcher(**matchers), response))
self.add_callback(lambda request: response, **matchers)

def add_callback(
self, callback: Callable[[httpx.Request], Optional[httpx.Response]], **matchers
Expand Down Expand Up @@ -187,26 +175,24 @@ def _handle_request(
) -> httpx.Response:
self._requests.append(request)

response = self._get_response(request)
if not response:
callback = self._get_callback(request)
if callback:
response = callback(request)
callback = self._get_callback(request)
if callback:
response = callback(request)

if response:
# Allow to read the response on client side
response.is_stream_consumed = False
response.is_closed = False
if hasattr(response, "_content"):
del response._content
return response
if response:
# Allow to read the response on client side
response.is_stream_consumed = False
response.is_closed = False
if hasattr(response, "_content"):
del response._content
return response

raise httpx.TimeoutException(
self._explain_that_no_response_was_found(request), request=request
)

def _explain_that_no_response_was_found(self, request: httpx.Request) -> str:
matchers = [matcher for matcher, _ in self._responses + self._callbacks]
matchers = [matcher for matcher, _ in self._callbacks]
expect_headers = set(
[
header
Expand All @@ -233,28 +219,6 @@ def _explain_that_no_response_was_found(self, request: httpx.Request) -> str:

return message

def _get_response(self, request: httpx.Request) -> Optional[httpx.Response]:
responses = [
(matcher, response)
for matcher, response in self._responses
if matcher.match(request)
]

# No response match this request
if not responses:
return None

# Responses match this request
for matcher, response in responses:
# Return the first not yet called
if not matcher.nb_calls:
matcher.nb_calls += 1
return response

# Or the last registered
matcher.nb_calls += 1
return response

def _get_callback(
self, request: httpx.Request
) -> Optional[Callable[[httpx.Request], Optional[httpx.Response]]]:
Expand Down Expand Up @@ -310,7 +274,7 @@ def get_request(self, **matchers) -> Optional[httpx.Request]:
return requests[0] if requests else None

def reset(self, assert_all_responses_were_requested: bool) -> None:
not_called = self._reset_responses() + self._reset_callbacks()
not_called = self._reset_callbacks()

if assert_all_responses_were_requested:
matchers_description = "\n".join([str(matcher) for matcher in not_called])
Expand All @@ -319,13 +283,6 @@ def reset(self, assert_all_responses_were_requested: bool) -> None:
not not_called
), f"The following responses are mocked but not requested:\n{matchers_description}"

def _reset_responses(self) -> List[_RequestMatcher]:
responses_not_called = [
matcher for matcher, _ in self._responses if not matcher.nb_calls
]
self._responses.clear()
return responses_not_called

def _reset_callbacks(self) -> List[_RequestMatcher]:
callbacks_not_executed = [
matcher for matcher, _ in self._callbacks if not matcher.nb_calls
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.17.3"
__version__ = "0.18.0"
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Topic :: Software Development :: Build Tools",
"Topic :: Internet :: WWW/HTTP",
"Framework :: Pytest",
Expand Down
92 changes: 46 additions & 46 deletions tests/test_httpx_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,52 +572,6 @@ async def test_with_headers(httpx_mock: HTTPXMock) -> None:
)


@pytest.mark.asyncio
async def test_deprecated_multipart_response(httpx_mock: HTTPXMock) -> None:
with pytest.warns(
DeprecationWarning,
match="data, files and boundary parameters will be removed in a future version. Use stream parameter with an instance of httpx._multipart.MultipartStream instead.",
):
httpx_mock.add_response(
url="https://test_url",
files={"file1": b"content of file 1"},
boundary=b"2256d3a36d2a61a1eba35a22bee5c74a",
)
with pytest.warns(
DeprecationWarning,
match="data, files and boundary parameters will be removed in a future version. Use stream parameter with an instance of httpx._multipart.MultipartStream instead.",
):
httpx_mock.add_response(
url="https://test_url",
data={"key1": "value1"},
files={"file1": b"content of file 1"},
boundary=b"2256d3a36d2a61a1eba35a22bee5c74a",
)

async with httpx.AsyncClient() as client:
response = await client.get("https://test_url")
assert (
response.text
== '--2256d3a36d2a61a1eba35a22bee5c74a\r\nContent-Disposition: form-data; name="file1"; filename="upload"\r\nContent-Type: application/octet-stream\r\n\r\ncontent of file 1\r\n--2256d3a36d2a61a1eba35a22bee5c74a--\r\n'
)

response = await client.get("https://test_url")
assert (
response.text
== """--2256d3a36d2a61a1eba35a22bee5c74a\r
Content-Disposition: form-data; name="key1"\r
\r
value1\r
--2256d3a36d2a61a1eba35a22bee5c74a\r
Content-Disposition: form-data; name="file1"; filename="upload"\r
Content-Type: application/octet-stream\r
\r
content of file 1\r
--2256d3a36d2a61a1eba35a22bee5c74a--\r
"""
)


@pytest.mark.asyncio
async def test_requests_retrieval(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
Expand Down Expand Up @@ -867,6 +821,52 @@ def custom_response(request: httpx.Request) -> httpx.Response:
assert response.headers["content-type"] == "application/json"


@pytest.mark.asyncio
async def test_callback_registered_after_response(httpx_mock: HTTPXMock) -> None:
def custom_response(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code=200, json=["content2"])

httpx_mock.add_response(json=["content1"])
httpx_mock.add_callback(custom_response)

async with httpx.AsyncClient() as client:
response = await client.get("https://test_url")
assert response.json() == ["content1"]
assert response.headers["content-type"] == "application/json"

response = await client.post("https://test_url")
assert response.json() == ["content2"]
assert response.headers["content-type"] == "application/json"

# Assert that the last registered callback is sent again even if there is a response
response = await client.post("https://test_url")
assert response.json() == ["content2"]
assert response.headers["content-type"] == "application/json"


@pytest.mark.asyncio
async def test_response_registered_after_callback(httpx_mock: HTTPXMock) -> None:
def custom_response(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code=200, json=["content1"])

httpx_mock.add_callback(custom_response)
httpx_mock.add_response(json=["content2"])

async with httpx.AsyncClient() as client:
response = await client.get("https://test_url")
assert response.json() == ["content1"]
assert response.headers["content-type"] == "application/json"

response = await client.post("https://test_url")
assert response.json() == ["content2"]
assert response.headers["content-type"] == "application/json"

# Assert that the last registered response is sent again even if there is a callback
response = await client.post("https://test_url")
assert response.json() == ["content2"]
assert response.headers["content-type"] == "application/json"


@pytest.mark.asyncio
async def test_callback_matching_method(httpx_mock: HTTPXMock) -> None:
def custom_response(request: httpx.Request) -> httpx.Response:
Expand Down
Loading

0 comments on commit 6890383

Please sign in to comment.