asyncly 0.6.2__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {asyncly-0.6.2 → asyncly-0.7.0}/PKG-INFO +64 -1
- {asyncly-0.6.2 → asyncly-0.7.0}/README.rst +63 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/base.py +17 -3
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/metrics/instrumentable_client.py +12 -3
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/pytest_plugin.py +10 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/__init__.py +3 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/models.py +2 -2
- asyncly-0.7.0/asyncly/srvmocker/proxy.py +164 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/pyproject.toml +1 -1
- {asyncly-0.6.2 → asyncly-0.7.0}/.gitignore +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/handlers/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/handlers/base.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/handlers/exceptions.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/handlers/json.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/handlers/msgspec.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/handlers/pydantic.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/metrics/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/metrics/route_resolver.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/metrics/sinks/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/metrics/sinks/base.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/metrics/sinks/noop.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/metrics/sinks/opentelemetry.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/metrics/sinks/prometheus.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/timeout.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/client/typing.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/py.typed +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/assertions.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/constants.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/exceptions.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/handlers.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/matching.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/responses/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/responses/base.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/responses/content.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/responses/json.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/responses/msgpack.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/responses/raw.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/responses/sequence.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/responses/timeout.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/responses/toml.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/responses/yaml.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/serialization/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/serialization/base.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/serialization/json.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/serialization/msgpack.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/serialization/toml.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/serialization/yaml.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.0}/asyncly/srvmocker/service.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: asyncly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Simple HTTP client and server for your integrations based on aiohttp
|
|
5
5
|
Project-URL: Homepage, https://github.com/andy-takker/asyncly
|
|
6
6
|
Project-URL: Source, https://github.com/andy-takker/asyncly
|
|
@@ -383,6 +383,69 @@ Pass an ``ssl.SSLContext`` to ``start_service`` to serve over HTTPS.
|
|
|
383
383
|
async with start_service(routes, ssl_context=ctx) as service:
|
|
384
384
|
...
|
|
385
385
|
|
|
386
|
+
Testing through a proxy
|
|
387
|
+
~~~~~~~~~~~~~~~~~~~~~~~
|
|
388
|
+
|
|
389
|
+
``BaseHttpClient`` accepts ``proxy`` and ``proxy_auth`` (forwarded to aiohttp_).
|
|
390
|
+
Set them once on the client, or override per request:
|
|
391
|
+
|
|
392
|
+
.. code-block:: python
|
|
393
|
+
|
|
394
|
+
from aiohttp import BasicAuth, ClientSession
|
|
395
|
+
from asyncly import BaseHttpClient
|
|
396
|
+
|
|
397
|
+
async with ClientSession() as session:
|
|
398
|
+
client = CatfactClient(
|
|
399
|
+
url=url,
|
|
400
|
+
session=session,
|
|
401
|
+
client_name="catfact",
|
|
402
|
+
proxy="http://127.0.0.1:8080",
|
|
403
|
+
proxy_auth=BasicAuth("user", "secret"),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
To test that a client genuinely routes through a proxy, ``start_proxy`` spins
|
|
407
|
+
up an in-process forwarding HTTP proxy. It records every request passing
|
|
408
|
+
through it and forwards it to the real target (typically another
|
|
409
|
+
``start_service``). Pair it with the ``mock_proxy`` fixture or use it directly:
|
|
410
|
+
|
|
411
|
+
.. code-block:: python
|
|
412
|
+
|
|
413
|
+
from aiohttp import BasicAuth, ClientSession
|
|
414
|
+
from asyncly.srvmocker import (
|
|
415
|
+
JsonResponse,
|
|
416
|
+
MockRoute,
|
|
417
|
+
start_proxy,
|
|
418
|
+
start_service,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
async def test_routes_through_proxy() -> None:
|
|
422
|
+
routes = [MockRoute("GET", "/fact", "fact")]
|
|
423
|
+
async with start_service(routes) as target:
|
|
424
|
+
target.register("fact", JsonResponse({"fact": "ok"}))
|
|
425
|
+
async with start_proxy(auth=BasicAuth("user", "secret")) as proxy:
|
|
426
|
+
async with ClientSession() as s:
|
|
427
|
+
resp = await s.get(
|
|
428
|
+
target.url / "fact",
|
|
429
|
+
proxy=proxy.url,
|
|
430
|
+
proxy_auth=BasicAuth("user", "secret"),
|
|
431
|
+
)
|
|
432
|
+
assert (await resp.json()) == {"fact": "ok"}
|
|
433
|
+
|
|
434
|
+
proxy.assert_called(times=1, method="GET")
|
|
435
|
+
|
|
436
|
+
``MockProxyService`` mirrors ``MockService``'s assertion helpers, reading from
|
|
437
|
+
the recorded history of forwarded requests:
|
|
438
|
+
|
|
439
|
+
- ``get_calls() -> list[RequestHistory]``
|
|
440
|
+
- ``last_call() -> RequestHistory`` (raises ``AssertionError`` if empty)
|
|
441
|
+
- ``assert_called(*, times=, target=, method=, json=, body=, headers=, query=)``
|
|
442
|
+
- ``assert_not_called()``
|
|
443
|
+
|
|
444
|
+
When ``start_proxy(auth=...)`` is set, requests missing or carrying a wrong
|
|
445
|
+
``Proxy-Authorization`` header get a ``407 Proxy Authentication Required`` and
|
|
446
|
+
are **not** forwarded. Only plain HTTP targets are supported (no ``CONNECT`` /
|
|
447
|
+
HTTPS tunnelling).
|
|
448
|
+
|
|
386
449
|
.. _PyPI: https://pypi.org/
|
|
387
450
|
.. _aiohttp: https://pypi.org/project/aiohttp/
|
|
388
451
|
.. _msgpack: https://msgpack.org
|
|
@@ -339,6 +339,69 @@ Pass an ``ssl.SSLContext`` to ``start_service`` to serve over HTTPS.
|
|
|
339
339
|
async with start_service(routes, ssl_context=ctx) as service:
|
|
340
340
|
...
|
|
341
341
|
|
|
342
|
+
Testing through a proxy
|
|
343
|
+
~~~~~~~~~~~~~~~~~~~~~~~
|
|
344
|
+
|
|
345
|
+
``BaseHttpClient`` accepts ``proxy`` and ``proxy_auth`` (forwarded to aiohttp_).
|
|
346
|
+
Set them once on the client, or override per request:
|
|
347
|
+
|
|
348
|
+
.. code-block:: python
|
|
349
|
+
|
|
350
|
+
from aiohttp import BasicAuth, ClientSession
|
|
351
|
+
from asyncly import BaseHttpClient
|
|
352
|
+
|
|
353
|
+
async with ClientSession() as session:
|
|
354
|
+
client = CatfactClient(
|
|
355
|
+
url=url,
|
|
356
|
+
session=session,
|
|
357
|
+
client_name="catfact",
|
|
358
|
+
proxy="http://127.0.0.1:8080",
|
|
359
|
+
proxy_auth=BasicAuth("user", "secret"),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
To test that a client genuinely routes through a proxy, ``start_proxy`` spins
|
|
363
|
+
up an in-process forwarding HTTP proxy. It records every request passing
|
|
364
|
+
through it and forwards it to the real target (typically another
|
|
365
|
+
``start_service``). Pair it with the ``mock_proxy`` fixture or use it directly:
|
|
366
|
+
|
|
367
|
+
.. code-block:: python
|
|
368
|
+
|
|
369
|
+
from aiohttp import BasicAuth, ClientSession
|
|
370
|
+
from asyncly.srvmocker import (
|
|
371
|
+
JsonResponse,
|
|
372
|
+
MockRoute,
|
|
373
|
+
start_proxy,
|
|
374
|
+
start_service,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
async def test_routes_through_proxy() -> None:
|
|
378
|
+
routes = [MockRoute("GET", "/fact", "fact")]
|
|
379
|
+
async with start_service(routes) as target:
|
|
380
|
+
target.register("fact", JsonResponse({"fact": "ok"}))
|
|
381
|
+
async with start_proxy(auth=BasicAuth("user", "secret")) as proxy:
|
|
382
|
+
async with ClientSession() as s:
|
|
383
|
+
resp = await s.get(
|
|
384
|
+
target.url / "fact",
|
|
385
|
+
proxy=proxy.url,
|
|
386
|
+
proxy_auth=BasicAuth("user", "secret"),
|
|
387
|
+
)
|
|
388
|
+
assert (await resp.json()) == {"fact": "ok"}
|
|
389
|
+
|
|
390
|
+
proxy.assert_called(times=1, method="GET")
|
|
391
|
+
|
|
392
|
+
``MockProxyService`` mirrors ``MockService``'s assertion helpers, reading from
|
|
393
|
+
the recorded history of forwarded requests:
|
|
394
|
+
|
|
395
|
+
- ``get_calls() -> list[RequestHistory]``
|
|
396
|
+
- ``last_call() -> RequestHistory`` (raises ``AssertionError`` if empty)
|
|
397
|
+
- ``assert_called(*, times=, target=, method=, json=, body=, headers=, query=)``
|
|
398
|
+
- ``assert_not_called()``
|
|
399
|
+
|
|
400
|
+
When ``start_proxy(auth=...)`` is set, requests missing or carrying a wrong
|
|
401
|
+
``Proxy-Authorization`` header get a ``407 Proxy Authentication Required`` and
|
|
402
|
+
are **not** forwarded. Only plain HTTP targets are supported (no ``CONNECT`` /
|
|
403
|
+
HTTPS tunnelling).
|
|
404
|
+
|
|
342
405
|
.. _PyPI: https://pypi.org/
|
|
343
406
|
.. _aiohttp: https://pypi.org/project/aiohttp/
|
|
344
407
|
.. _msgpack: https://msgpack.org
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from typing import Any
|
|
2
2
|
|
|
3
|
-
from aiohttp import ClientSession
|
|
3
|
+
from aiohttp import BasicAuth, ClientSession
|
|
4
4
|
from aiohttp.client import DEFAULT_TIMEOUT
|
|
5
5
|
from yarl import URL
|
|
6
6
|
|
|
@@ -13,18 +13,28 @@ from asyncly.client.typing import MethodType
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class BaseHttpClient:
|
|
16
|
-
__slots__ = ("_url", "_session", "_client_name")
|
|
16
|
+
__slots__ = ("_url", "_session", "_client_name", "_proxy", "_proxy_auth")
|
|
17
17
|
|
|
18
18
|
_url: URL
|
|
19
19
|
_session: ClientSession
|
|
20
20
|
_client_name: str
|
|
21
|
+
_proxy: URL | None
|
|
22
|
+
_proxy_auth: BasicAuth | None
|
|
21
23
|
|
|
22
24
|
def __init__(
|
|
23
|
-
self,
|
|
25
|
+
self,
|
|
26
|
+
url: URL | str,
|
|
27
|
+
session: ClientSession,
|
|
28
|
+
client_name: str,
|
|
29
|
+
*,
|
|
30
|
+
proxy: URL | str | None = None,
|
|
31
|
+
proxy_auth: BasicAuth | None = None,
|
|
24
32
|
) -> None:
|
|
25
33
|
self._url = url if isinstance(url, URL) else URL(url)
|
|
26
34
|
self._session = session
|
|
27
35
|
self._client_name = client_name
|
|
36
|
+
self._proxy = URL(proxy) if isinstance(proxy, str) else proxy
|
|
37
|
+
self._proxy_auth = proxy_auth
|
|
28
38
|
|
|
29
39
|
@property
|
|
30
40
|
def url(self) -> URL:
|
|
@@ -38,6 +48,10 @@ class BaseHttpClient:
|
|
|
38
48
|
timeout: TimeoutType = DEFAULT_TIMEOUT,
|
|
39
49
|
**kwargs: Any,
|
|
40
50
|
) -> Any:
|
|
51
|
+
if "proxy" not in kwargs and self._proxy is not None:
|
|
52
|
+
kwargs["proxy"] = self._proxy
|
|
53
|
+
if "proxy_auth" not in kwargs and self._proxy_auth is not None:
|
|
54
|
+
kwargs["proxy_auth"] = self._proxy_auth
|
|
41
55
|
async with self._session.request(
|
|
42
56
|
method=method,
|
|
43
57
|
url=url,
|
|
@@ -3,7 +3,7 @@ from time import perf_counter
|
|
|
3
3
|
from types import TracebackType
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
-
from aiohttp import ClientResponse, ClientSession
|
|
6
|
+
from aiohttp import BasicAuth, ClientResponse, ClientSession
|
|
7
7
|
from aiohttp.client import DEFAULT_TIMEOUT
|
|
8
8
|
from yarl import URL
|
|
9
9
|
|
|
@@ -16,15 +16,24 @@ from asyncly.client.typing import ResponseHandler, ResponseHandlersType, RouteRe
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class InstrumentableHttpClient(BaseHttpClient):
|
|
19
|
-
__slots__ = ("_metrics_sink", "_resolve_route")
|
|
19
|
+
__slots__ = ("_metrics_sink", "_resolve_route")
|
|
20
20
|
|
|
21
21
|
def __init__(
|
|
22
22
|
self,
|
|
23
23
|
url: URL | str,
|
|
24
24
|
session: ClientSession,
|
|
25
25
|
client_name: str,
|
|
26
|
+
*,
|
|
27
|
+
proxy: URL | str | None = None,
|
|
28
|
+
proxy_auth: BasicAuth | None = None,
|
|
26
29
|
) -> None:
|
|
27
|
-
super().__init__(
|
|
30
|
+
super().__init__(
|
|
31
|
+
url=url,
|
|
32
|
+
session=session,
|
|
33
|
+
client_name=client_name,
|
|
34
|
+
proxy=proxy,
|
|
35
|
+
proxy_auth=proxy_auth,
|
|
36
|
+
)
|
|
28
37
|
self._metrics_sink: MetricsSink = NoopSink()
|
|
29
38
|
self._resolve_route: RouteResolver = default_route_resolver
|
|
30
39
|
|
|
@@ -18,6 +18,7 @@ import pytest
|
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from asyncly.srvmocker.models import MockRoute, MockService
|
|
21
|
+
from asyncly.srvmocker.proxy import MockProxyService
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
@pytest.fixture
|
|
@@ -34,3 +35,12 @@ async def mock_service(
|
|
|
34
35
|
|
|
35
36
|
async with start_service(mock_routes) as service:
|
|
36
37
|
yield service
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
async def mock_proxy() -> "AsyncIterator[MockProxyService]":
|
|
42
|
+
"""A forwarding mock proxy; point a client at it via ``proxy=mock_proxy.url``."""
|
|
43
|
+
from asyncly.srvmocker import start_proxy
|
|
44
|
+
|
|
45
|
+
async with start_proxy() as proxy:
|
|
46
|
+
yield proxy
|
|
@@ -5,6 +5,7 @@ from asyncly.srvmocker.exceptions import (
|
|
|
5
5
|
)
|
|
6
6
|
from asyncly.srvmocker.matching import Match
|
|
7
7
|
from asyncly.srvmocker.models import MockRoute, MockService
|
|
8
|
+
from asyncly.srvmocker.proxy import MockProxyService, start_proxy
|
|
8
9
|
from asyncly.srvmocker.responses.base import BaseMockResponse
|
|
9
10
|
from asyncly.srvmocker.responses.content import ContentResponse
|
|
10
11
|
from asyncly.srvmocker.responses.json import JsonResponse
|
|
@@ -17,6 +18,7 @@ __all__ = (
|
|
|
17
18
|
"ContentResponse",
|
|
18
19
|
"JsonResponse",
|
|
19
20
|
"Match",
|
|
21
|
+
"MockProxyService",
|
|
20
22
|
"MockRoute",
|
|
21
23
|
"MockService",
|
|
22
24
|
"RawResponse",
|
|
@@ -24,5 +26,6 @@ __all__ = (
|
|
|
24
26
|
"SequenceResponse",
|
|
25
27
|
"SrvMockerError",
|
|
26
28
|
"UnknownHandlerError",
|
|
29
|
+
"start_proxy",
|
|
27
30
|
"start_service",
|
|
28
31
|
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from collections.abc import MutableMapping, MutableSequence
|
|
2
2
|
from dataclasses import dataclass
|
|
3
3
|
|
|
4
|
-
from aiohttp.web_request import
|
|
4
|
+
from aiohttp.web_request import BaseRequest
|
|
5
5
|
from yarl import URL
|
|
6
6
|
|
|
7
7
|
from asyncly.srvmocker.matching import Match
|
|
@@ -18,7 +18,7 @@ class MockRoute:
|
|
|
18
18
|
|
|
19
19
|
@dataclass
|
|
20
20
|
class RequestHistory:
|
|
21
|
-
request:
|
|
21
|
+
request: BaseRequest
|
|
22
22
|
body: bytes
|
|
23
23
|
|
|
24
24
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""In-process forwarding HTTP proxy for tests.
|
|
2
|
+
|
|
3
|
+
`start_proxy` spins up a real forward proxy (via aiohttp's ``RawTestServer``)
|
|
4
|
+
that records every request passing through it and forwards it to the absolute
|
|
5
|
+
target URL the client requested. Combined with :func:`start_service` it lets
|
|
6
|
+
you test that a client genuinely routes through a proxy::
|
|
7
|
+
|
|
8
|
+
async with start_service([MockRoute("GET", "/x", "ok")]) as target:
|
|
9
|
+
target.register("ok", JsonResponse({"ok": True}))
|
|
10
|
+
async with start_proxy() as proxy:
|
|
11
|
+
async with ClientSession() as s:
|
|
12
|
+
resp = await s.get(target.url / "x", proxy=proxy.url)
|
|
13
|
+
proxy.assert_called(times=1, method="GET")
|
|
14
|
+
|
|
15
|
+
Only plain HTTP targets are supported (no ``CONNECT`` / HTTPS tunnelling).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from collections.abc import AsyncGenerator
|
|
19
|
+
from contextlib import asynccontextmanager
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from http import HTTPStatus
|
|
22
|
+
|
|
23
|
+
from aiohttp import BasicAuth, ClientSession
|
|
24
|
+
from aiohttp.test_utils import RawTestServer
|
|
25
|
+
from aiohttp.web_request import BaseRequest
|
|
26
|
+
from aiohttp.web_response import Response
|
|
27
|
+
from multidict import CIMultiDict, CIMultiDictProxy
|
|
28
|
+
from yarl import URL
|
|
29
|
+
|
|
30
|
+
from asyncly.srvmocker.assertions import call_matches
|
|
31
|
+
from asyncly.srvmocker.models import RequestHistory
|
|
32
|
+
|
|
33
|
+
# Hop-by-hop headers must not be forwarded end-to-end (RFC 9110 section 7.6.1),
|
|
34
|
+
# plus the proxy-specific ones.
|
|
35
|
+
_HOP_BY_HOP = frozenset(
|
|
36
|
+
h.lower()
|
|
37
|
+
for h in (
|
|
38
|
+
"connection",
|
|
39
|
+
"keep-alive",
|
|
40
|
+
"proxy-authenticate",
|
|
41
|
+
"proxy-authorization",
|
|
42
|
+
"proxy-connection",
|
|
43
|
+
"te",
|
|
44
|
+
"trailer",
|
|
45
|
+
"transfer-encoding",
|
|
46
|
+
"upgrade",
|
|
47
|
+
"host",
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
# On the response we additionally drop Content-Length so aiohttp recomputes it
|
|
51
|
+
# from the (verbatim) body we relay.
|
|
52
|
+
_HOP_BY_HOP_RESPONSE = _HOP_BY_HOP | frozenset({"content-length"})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class MockProxyService:
|
|
57
|
+
url: URL
|
|
58
|
+
history: list[RequestHistory] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
def set_url(self, url: URL) -> None:
|
|
61
|
+
object.__setattr__(self, "url", url)
|
|
62
|
+
|
|
63
|
+
def get_calls(self) -> list[RequestHistory]:
|
|
64
|
+
return list(self.history)
|
|
65
|
+
|
|
66
|
+
def last_call(self) -> RequestHistory:
|
|
67
|
+
if not self.history:
|
|
68
|
+
raise AssertionError("no requests recorded by proxy")
|
|
69
|
+
return self.history[-1]
|
|
70
|
+
|
|
71
|
+
def assert_called(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
times: int | None = None,
|
|
75
|
+
target: URL | str | None = None,
|
|
76
|
+
method: str | None = None,
|
|
77
|
+
json: object = None,
|
|
78
|
+
body: bytes | None = None,
|
|
79
|
+
headers: dict[str, str] | None = None,
|
|
80
|
+
query: dict[str, str] | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
calls = self.get_calls()
|
|
83
|
+
if times is not None and len(calls) != times:
|
|
84
|
+
raise AssertionError(f"proxy: expected {times} call(s), got {len(calls)}")
|
|
85
|
+
criteria = (target, method, json, body, headers, query)
|
|
86
|
+
if times is not None and all(v is None for v in criteria):
|
|
87
|
+
return
|
|
88
|
+
if not calls:
|
|
89
|
+
raise AssertionError("proxy: expected at least one call, got 0")
|
|
90
|
+
for call in calls:
|
|
91
|
+
if target is not None and str(call.request.url) != str(URL(target)):
|
|
92
|
+
continue
|
|
93
|
+
if method is not None and call.request.method != method:
|
|
94
|
+
continue
|
|
95
|
+
if call_matches(call, json=json, body=body, headers=headers, query=query):
|
|
96
|
+
return
|
|
97
|
+
raise AssertionError(
|
|
98
|
+
f"proxy: none of {len(calls)} call(s) matched the given criteria"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def assert_not_called(self) -> None:
|
|
102
|
+
if self.history:
|
|
103
|
+
raise AssertionError(f"proxy: expected no calls, got {len(self.history)}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _relay_headers(
|
|
107
|
+
source: CIMultiDictProxy[str], *, drop: frozenset[str]
|
|
108
|
+
) -> CIMultiDict[str]:
|
|
109
|
+
"""Copy headers preserving duplicates (e.g. multiple ``Set-Cookie``)."""
|
|
110
|
+
relayed: CIMultiDict[str] = CIMultiDict()
|
|
111
|
+
for key, value in source.items():
|
|
112
|
+
if key.lower() not in drop:
|
|
113
|
+
relayed.add(key, value)
|
|
114
|
+
return relayed
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@asynccontextmanager
|
|
118
|
+
async def start_proxy(
|
|
119
|
+
*,
|
|
120
|
+
auth: BasicAuth | None = None,
|
|
121
|
+
) -> AsyncGenerator[MockProxyService, None]:
|
|
122
|
+
proxy = MockProxyService(url=URL())
|
|
123
|
+
expected_auth = auth.encode() if auth is not None else None
|
|
124
|
+
|
|
125
|
+
# Relay target responses verbatim (HTTP, uncompressed): keep the body and
|
|
126
|
+
# its Content-Encoding untouched instead of auto-decompressing.
|
|
127
|
+
forward_session = ClientSession(auto_decompress=False)
|
|
128
|
+
|
|
129
|
+
async def _handler(request: BaseRequest) -> Response:
|
|
130
|
+
body = await request.read()
|
|
131
|
+
proxy.history.append(RequestHistory(request=request, body=body))
|
|
132
|
+
|
|
133
|
+
if expected_auth is not None:
|
|
134
|
+
provided = request.headers.get("Proxy-Authorization")
|
|
135
|
+
if provided != expected_auth:
|
|
136
|
+
return Response(
|
|
137
|
+
status=HTTPStatus.PROXY_AUTHENTICATION_REQUIRED,
|
|
138
|
+
reason="Proxy Authentication Required",
|
|
139
|
+
headers={"Proxy-Authenticate": "Basic"},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async with forward_session.request(
|
|
143
|
+
method=request.method,
|
|
144
|
+
url=request.url,
|
|
145
|
+
headers=_relay_headers(request.headers, drop=_HOP_BY_HOP),
|
|
146
|
+
data=body or None,
|
|
147
|
+
allow_redirects=False,
|
|
148
|
+
) as upstream:
|
|
149
|
+
payload = await upstream.read()
|
|
150
|
+
return Response(
|
|
151
|
+
status=upstream.status,
|
|
152
|
+
reason=upstream.reason,
|
|
153
|
+
headers=_relay_headers(upstream.headers, drop=_HOP_BY_HOP_RESPONSE),
|
|
154
|
+
body=payload,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
server = RawTestServer(_handler)
|
|
158
|
+
try:
|
|
159
|
+
await server.start_server()
|
|
160
|
+
proxy.set_url(server.make_url(""))
|
|
161
|
+
yield proxy
|
|
162
|
+
finally:
|
|
163
|
+
await server.close()
|
|
164
|
+
await forward_session.close()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "asyncly"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
4
4
|
description = "Simple HTTP client and server for your integrations based on aiohttp"
|
|
5
5
|
authors = [{ name = "Sergey Natalenko", email = "sergey.natalenko@mail.ru" }]
|
|
6
6
|
requires-python = ">=3.10, <4"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|