Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d75250e
feat: Add aiohttp support
jab3z Jun 26, 2025
96cbd82
docs: Restructure README for clarity
jab3z Jun 26, 2025
8a93a4d
docs: Further clarify MockSet behavior for all libraries
jab3z Jun 26, 2025
efe176c
Fix: Address black formatting issues and conditionally load aiorespon…
jab3z Jun 26, 2025
5114a82
feat: Add aiohttp exception test case
jab3z Jun 26, 2025
1fca506
Fix: Update aioresponses version in requirements_dev.txt for Python 3…
jab3z Jun 26, 2025
c448004
Fix: Update black version in requirements_dev.txt to 25.1.0 for GitHu…
jab3z Jun 26, 2025
fbb655c
Fix: Resolve flake8 F401 unused import in conftest.py
jab3z Jun 26, 2025
8a7d760
feat: Move pytest_plugins to root conftest.py and fix aioresponses ex…
jab3z Jun 26, 2025
0f6a5ad
Fix: Correctly pass OSError to ClientConnectorError in aiohttp except…
jab3z Jun 26, 2025
d2b7a99
Fix: Update aiohttp exception test to assert OSError
jab3z Jun 26, 2025
b00640b
fix(ci): remove unused import in aiohttp test
jab3z Jun 27, 2025
c5c0345
fix(tests): use setup_http_mocks instead of deprecated setup_api_mocks
jab3z Jun 27, 2025
e41139b
fix(ci): correct scope for aiohttp_mock_session fixture
jab3z Jun 27, 2025
2d1f82f
fix(ci): refactor aiohttp mock fixture
jab3z Jun 27, 2025
6a635e3
Refactor: Add MockAIOAPIResponse for advanced aiohttp features
jab3z Jun 27, 2025
74c83e9
feat(mocking): add headers and callback parameters to MockAPIResponse
jab3z Jun 27, 2025
03f6865
Merge remote-tracking branch 'origin/feature/aiohttp-support' into fe…
jab3z Jun 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## [1.4.0] - 2024-06-27

### Added
- Added `headers` and `callback` parameters to `MockAPIResponse` for advanced mocking scenarios.
- The `headers` parameter is available for all supported libraries.
- The `callback` parameter is available for `aiohttp`, `requests` and `httpx`.

### Changed
- Removed `MockAIOAPIResponse` in favor of using `MockAPIResponse` for all `aiohttp` mocking.

## [1.3.0] - 2024-02-05

Expand Down
577 changes: 267 additions & 310 deletions README.md

Large diffs are not rendered by default.

Empty file added conftest.py
Empty file.
95 changes: 95 additions & 0 deletions multi_api_mocker/aiohttp_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from typing import List, Callable
from aioresponses import aioresponses
from multi_api_mocker.definitions import MockAPIResponse


class MockAIOAPIResponse(MockAPIResponse):
"""
A specialized MockAPIResponse for aiohttp with advanced features.

This class extends MockAPIResponse to include support for aioresponses-specific
features such as custom headers, raw body content, and dynamic callbacks. It ensures
that advanced features are only used with this specialized class, raising a
TypeError if they are attempted with the base MockAPIResponse.

Attributes:
default_headers (dict): Default headers for the response.
default_body (bytes): Default body for the response.
default_callback (Callable): Default callback for the response.
"""

default_headers: dict | None = None
default_body: bytes | None = None
default_callback: Callable | None = None

def __init__(
self,
*args,
headers=None,
body=None,
callback=None,
**kwargs,
):
"""
Initializes the MockAIOAPIResponse with advanced options.

Args:
headers (dict, optional): The headers of the response.
body (bytes, optional): The body of the response.
callback (Callable, optional): The callback to execute.
**kwargs: Additional keyword arguments for the base class.
"""
super().__init__(*args, **kwargs)
self._headers = headers
self._body = body
self._callback = callback

if (headers or body or callback) and not isinstance(self, MockAIOAPIResponse):
raise TypeError(
"Advanced features like headers, body, and callback are only "
"available with MockAIOAPIResponse."
)

@property
def headers(self):
return self._headers or self.__class__.default_headers

@property
def body(self):
return self._body or self.__class__.default_body

@property
def callback(self):
return self._callback or self.__class__.default_callback


class AIOHTTPMockSet:
"""
A collection class that manages MockAPIResponse objects and integrates with the
aioresponses fixture. This class provides efficient access and iteration over
grouped API responses by their endpoint names, simplifying the process of setting
up and managing multiple mock responses in tests for aiohttp.
"""

def __init__(
self,
api_responses: List[MockAPIResponse],
aiohttp_mock: aioresponses,
):
self._response_registry = {
response.endpoint_name: response for response in api_responses
}
self.aiohttp_mock = aiohttp_mock

def __getitem__(self, endpoint_name: str) -> MockAPIResponse:
return self._response_registry[endpoint_name]

def __iter__(self):
return iter(self._response_registry.values())

def __len__(self):
return len(self._response_registry)

def __repr__(self):
endpoint_names = ", ".join(self._response_registry.keys())
return f"<{self.__class__.__name__} with endpoints: {endpoint_names}>"
161 changes: 92 additions & 69 deletions multi_api_mocker/contrib/pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,59 +22,28 @@
except ImportError:
httpx_available = False

try:
from aioresponses import aioresponses # type: ignore # noqa: F401
from ..aiohttp_utils import AIOHTTPMockSet # noqa: F401

aiohttp_available = True
except ImportError:
aiohttp_available = False


if requests_mock_available:

@pytest.fixture(scope="function")
def setup_http_mocks(requests_mock: Mocker, request) -> RequestsMockSet:
"""
A pytest fixture for configuring mock HTTP responses in a test environment.
It takes subclasses of MockAPIResponse, each representing a unique API call
configuration. These subclasses facilitate the creation of simple or complex
response flows, simulating real-world API interactions.

Parameters:
requests_mock (Mocker): The pytest requests_mock fixture.
request: The pytest request object containing parametrized test data.

Returns:
RequestsMockSet: An instance of MockSet containing the organized
MockAPIResponse objects, ready for use in tests.

The fixture supports multiple test scenarios, allowing for thorough
testing of varying API response conditions. This is especially useful
for simulating sequences of API calls like Fork, Commit, and Push
in a version control system context.

Example Usage:
- Single API Call Test:
@pytest.mark.parametrize("setup_http_mocks", [([Fork()])], indirect=True)

- Multi-call Sequence Test:
@pytest.mark.parametrize(
"setup_http_mocks", [([Fork(), Commit(), Push()])], indirect=True
)

- Testing Multiple Scenarios:
@pytest.mark.parametrize(
"setup_http_mocks",
[([Fork(), Commit(), Push()]), ([Fork(), Commit(), ForcePush()])],
indirect=True
)


This fixture converts the list of MockAPIResponse subclasses into
MockConfiguration instances, registers them with requests_mock,
and returns a MockSet object, which allows querying each mock
by its endpoint name.
"""
if not requests_mock_available:
pytest.skip("requests-mock is not installed")
yield from configure_http_mocks(requests_mock, request)

# Deprecated wrapper fixture
@pytest.fixture(scope="function")
def setup_api_mocks(requests_mock: Mocker, request) -> RequestsMockSet:
"""
Deprecated: Use `setup_http_mocks` instead.
"""
if not requests_mock_available:
pytest.skip("requests-mock is not installed")
warnings.warn(
"`setup_api_mocks` is deprecated and will be removed in a future release. "
"Please use `setup_http_mocks` instead.",
Expand All @@ -88,8 +57,20 @@ def configure_http_mocks(requests_mock: Mocker, request):
matchers = {}

for api_mock in api_mocks_configurations:
responses = []
for response in api_mock.responses:
response_data = {
key: response.get(key)
for key in ("json", "status_code", "headers", "exc")
if response.get(key) is not None
}
if response.get("callback") is not None:
response_data["json"] = response.get("callback")
responses.append(response_data)
matcher = requests_mock.register_uri(
api_mock.method, api_mock.url, api_mock.responses
api_mock.method,
api_mock.url,
response_list=responses,
)
matchers[api_mock.url] = matcher

Expand All @@ -100,34 +81,22 @@ def configure_http_mocks(requests_mock: Mocker, request):

@pytest.fixture(scope="function")
def setup_httpx_mocks(httpx_mock: HTTPXMock, request) -> HTTPXMockSet:
"""
A pytest fixture for configuring mock HTTPX responses in a test environment.
Directly registers each mock response for HTTPX, leveraging pytest-httpx's
ability to queue multiple responses for the same URL and method.

Parameters:
httpx_mock (HTTPXMock): The pytest-httpx fixture for mocking HTTPX requests.
request: The pytest request object containing parameterized test data.

Returns:
HTTPXMockSet: An instance of HttpxMockSet containing the organized
MockAPIResponse objects, ready for use in tests.

Usage in tests is similar to the original setup_api_mocks, using pytest's
parametrize decorator to supply mock response definitions.
"""
mock_definitions: List[
Union[MockAPIResponse, List[MockAPIResponse]]
] = request.param

if not httpx_available:
pytest.skip("pytest-httpx is not installed")
mock_definitions: List[Union[MockAPIResponse, List[MockAPIResponse]]] = (
request.param
)
flattened_definitions = []
for mock_definition in mock_definitions:
if isinstance(mock_definition, list):
for nested_mock_definition in mock_definition:
add_response(httpx_mock, nested_mock_definition)
flattened_definitions.extend(mock_definition)
else:
add_response(httpx_mock, mock_definition)
flattened_definitions.append(mock_definition)

yield HTTPXMockSet(mock_definitions, httpx_mock)
for mock_definition in flattened_definitions:
add_response(httpx_mock, mock_definition)

yield HTTPXMockSet(flattened_definitions, httpx_mock)

def add_response(httpx_mock: HTTPXMock, mock_definition: MockAPIResponse):
if not isinstance(mock_definition, MockAPIResponse):
Expand All @@ -140,10 +109,64 @@ def add_response(httpx_mock: HTTPXMock, mock_definition: MockAPIResponse):
method=mock_definition.method,
exception=mock_definition.exc,
)
elif mock_definition.callback:
httpx_mock.add_callback(
url=mock_definition.url,
method=mock_definition.method,
callback=mock_definition.callback,
)
else:
httpx_mock.add_response(
url=mock_definition.url,
method=mock_definition.method,
json=mock_definition.json,
text=mock_definition.text,
status_code=mock_definition.status_code,
headers=mock_definition.headers,
)


if aiohttp_available:

@pytest.fixture
def setup_aiohttp_mocks(request) -> AIOHTTPMockSet:
if not aiohttp_available:
pytest.skip("aioresponses is not installed")
with aioresponses() as m:
mock_definitions: List[Union[MockAPIResponse, List[MockAPIResponse]]] = (
request.param
)
flattened_definitions = []
for mock_definition in mock_definitions:
if isinstance(mock_definition, list):
flattened_definitions.extend(mock_definition)
else:
flattened_definitions.append(mock_definition)

for mock_definition in flattened_definitions:
add_aiohttp_response(m, mock_definition)

yield AIOHTTPMockSet(flattened_definitions, m)

def add_aiohttp_response(
aiohttp_mock: aioresponses, mock_definition: MockAPIResponse
):
if not isinstance(mock_definition, MockAPIResponse):
raise ValueError(
f"Unsupported mock definition type: {type(mock_definition)}"
)
if mock_definition.exc:
aiohttp_mock.add(
url=mock_definition.url,
method=mock_definition.method.upper(),
exception=mock_definition.exc,
)
else:
aiohttp_mock.add(
url=mock_definition.url,
method=mock_definition.method.upper(),
payload=mock_definition.json,
status=mock_definition.status_code,
headers=mock_definition.headers,
callback=mock_definition.callback,
)
22 changes: 21 additions & 1 deletion multi_api_mocker/definitions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import inspect
import re
from typing import Any
from typing import Any, Callable


class MockAPIResponse:
Expand Down Expand Up @@ -48,6 +48,8 @@ class MockAPIResponse:
default_json: Any | None = None
default_text: str | None = None
default_exc: Exception | type[Exception] | None = None
default_headers: dict | None = None
default_callback: Callable | None = None

def __init__(
self,
Expand All @@ -59,6 +61,8 @@ def __init__(
text=None,
endpoint_name=None,
exc=None,
headers=None,
callback=None,
**kwargs,
):
"""
Expand All @@ -82,6 +86,10 @@ def __init__(
the class name.
exc (Union[Exception, Type[Exception], None], optional): Exception to raise
for the request. Defaults to None.
headers (dict, optional): The headers of the response. Defaults to the
class-level `headers` attribute.
callback (Callable, optional): The callback to execute when the request is
made. This is only supported for aiohttp.
**kwargs: Additional keyword arguments for customizing the response.
"""

Expand All @@ -95,6 +103,8 @@ def __init__(
self._json = json
self._text = text
self._exc = exc
self._headers = headers
self._callback = callback
self.kwargs = kwargs

def __repr__(self):
Expand All @@ -115,6 +125,8 @@ def validate_class_attributes(cls):
"endpoint_name": (str,),
"default_status_code": (int, type(None)),
"default_text": (str, type(None)),
"default_headers": (dict, type(None)),
"default_callback": (Callable, type(None)),
}

for attr, expected_types in expected_class_attribute_types.items():
Expand Down Expand Up @@ -173,6 +185,14 @@ def text(self):
def exc(self):
return self._exc or self.__class__.default_exc

@property
def headers(self):
return self._headers or self.__class__.default_headers

@property
def callback(self):
return self._callback or self.__class__.default_callback

def _default_json(self, status_code):
return self.default_json.copy() if self.default_json else None

Expand Down
Loading
Loading