Skip to content

Commit a94734d

Browse files
committed
initial commit
1 parent 976e493 commit a94734d

File tree

14 files changed

+1035
-0
lines changed

14 files changed

+1035
-0
lines changed

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

Makefile

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
lint:
2+
ruff format .
3+
ruff check --fix .
4+
mypy .
5+
flake8 .
6+
7+
test:
8+
pytest --cov async_pytest_httpserver tests --cov-report=term-missing
9+
10+
test_fastapi:
11+
pytest examples/fastapi_example/tests
12+
13+
test_starlette:
14+
pytest examples/starlette_example/tests
15+
16+
uv:
17+
uv sync
18+
source .venv/bin/activate
19+
20+
build:
21+
uv build
22+
23+
publish:
24+
uv publish
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from .web_service_mock import MockData, WebServiceMock
2+
from .fixtures import external_service_mock
3+
4+
__all__ = [
5+
"MockData",
6+
"WebServiceMock",
7+
"external_service_mock",
8+
]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from typing import Callable
2+
3+
import pytest_asyncio
4+
from aiohttp import web
5+
6+
from .web_service_mock import MockData, WebServiceMock
7+
8+
9+
@pytest_asyncio.fixture
10+
async def external_service_mock(aiohttp_server):
11+
"""
12+
Mock server for an external service
13+
14+
Returns:
15+
function for adding an API to the server
16+
"""
17+
18+
async def _create_mock() -> tuple[
19+
str, Callable[[MockData], list[dict[str, any]]]
20+
]:
21+
app = web.Application()
22+
web_service = WebServiceMock()
23+
# The route catches requests for any path with any method.
24+
# The web_service is responsible for actual routing, allowing new
25+
# APIs to be added at server runtime.
26+
app.router.add_route("*", "/{tail:.+}", web_service.handle)
27+
server = await aiohttp_server(app)
28+
return f"http://{server.host}:{server.port}", web_service.add_mock_data
29+
30+
return _create_mock
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from copy import deepcopy
2+
from dataclasses import dataclass
3+
from inspect import isawaitable
4+
from typing import Awaitable, Callable
5+
6+
from aiohttp import web
7+
8+
9+
@dataclass
10+
class MockData:
11+
method: str # the method we replace
12+
path: str # the API path we are replacing
13+
response: (
14+
web.Response
15+
| Callable[[web.Request], web.Response | Awaitable[web.Response]]
16+
)
17+
18+
19+
class WebServiceMock:
20+
"""
21+
A mock web service with a single API handle.
22+
Intended use:
23+
1. Start aiohttp_server with a universal route to the handle
24+
2. Add real APIs via add_mock_data
25+
"""
26+
27+
def __init__(self):
28+
self._mock_data: list[MockData] = []
29+
self._call_info = {}
30+
31+
async def handle(self, request: web.Request) -> web.Response:
32+
"""
33+
The method searches for a mock among the registered MockData,
34+
stores the request information, and returns a mock response.
35+
"""
36+
for mock in self._mock_data:
37+
if (
38+
mock.method.lower() == request.method.lower()
39+
and mock.path == request.path
40+
):
41+
await self._save_request(mock.method, mock.path, request)
42+
if isinstance(mock.response, web.Response):
43+
return deepcopy(mock.response)
44+
else:
45+
response = mock.response(request)
46+
if isawaitable(response):
47+
return await response
48+
else:
49+
return response
50+
51+
raise Exception(
52+
f"Mock with method={request.method} "
53+
f"and url={request.path} not found"
54+
)
55+
56+
def add_mock_data(self, mock_data: MockData) -> list[dict[str, any]]:
57+
"""Saves a new mock and returns a reference to the call history"""
58+
self._mock_data.append(mock_data)
59+
60+
url_data = self._call_info.get(mock_data.path) or {}
61+
method_data = url_data.get(mock_data.method) or []
62+
url_data[mock_data.method] = method_data
63+
self._call_info[mock_data.path] = url_data
64+
return self._call_info[mock_data.path][mock_data.method]
65+
66+
async def _save_request(
67+
self, method: str, path: str, request: web.Request
68+
) -> None:
69+
data = {"headers": request.headers}
70+
if request.can_read_body:
71+
if request.content_type == "application/json":
72+
data["json"] = await request.json()
73+
if request.content_type == "text/plain":
74+
data["text"] = await request.text()
75+
76+
self._call_info[path][method].append(data)

mypy.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[mypy]
2+
strict = true
3+
pretty = true

pyproject.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[project]
2+
name = "async-pytest-httpserver"
3+
version = "0.1.0"
4+
description = "Async mock HTTP server for pytest, built on top of aiohttp."
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
dependencies = [
8+
"aiohttp>=3.13.2",
9+
"pytest>=9.0.2",
10+
"pytest-aiohttp>=1.1.0",
11+
"pytest-asyncio>=1.3.0",
12+
]
13+
14+
[dependency-groups]
15+
dev = [
16+
"mypy>=1.19.0",
17+
"pytest-cov>=7.0.0",
18+
"ruff>=0.14.8",
19+
"wemake-python-styleguide>=1.4.0",
20+
]
21+
22+
[project.entry-points.pytest11]
23+
my_fixtures = "async_pytest_httpserver.fixtures"

ruff.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
line-length = 79
2+
exclude = [
3+
".venv"
4+
]

setup.cfg

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[flake8]
2+
exclude = .venv
3+
;ignore=WPS476, WPS300, WPS410, WPS412, WPS501, WPS229, WPS202, WPS110, WPS473
4+
; WPS217, WPS430, WPS420
5+
;
6+
;per-file-ignores =
7+
; examples/*: WPS110, WPS432, WPS204, WPS114, WPS226, WPS201, WPS213

tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)