asyncly 0.6.2__tar.gz → 0.7.1__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.7.1/PKG-INFO +143 -0
- asyncly-0.7.1/README.md +98 -0
- asyncly-0.7.1/asyncly/client/base.py +117 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/handlers/exceptions.py +9 -1
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/handlers/json.py +12 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/handlers/msgspec.py +16 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/handlers/pydantic.py +14 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/instrumentable_client.py +32 -3
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/route_resolver.py +6 -0
- asyncly-0.7.1/asyncly/client/metrics/sinks/base.py +30 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/sinks/noop.py +1 -1
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/sinks/opentelemetry.py +8 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/sinks/prometheus.py +12 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/pytest_plugin.py +10 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/__init__.py +3 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/models.py +64 -2
- asyncly-0.7.1/asyncly/srvmocker/proxy.py +203 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/content.py +13 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/json.py +8 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/msgpack.py +2 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/timeout.py +7 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/toml.py +2 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/yaml.py +2 -0
- asyncly-0.7.1/asyncly/srvmocker/serialization/base.py +19 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/service.py +22 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/pyproject.toml +9 -3
- asyncly-0.6.2/PKG-INFO +0 -409
- asyncly-0.6.2/README.rst +0 -365
- asyncly-0.6.2/asyncly/client/base.py +0 -51
- asyncly-0.6.2/asyncly/client/metrics/sinks/base.py +0 -14
- asyncly-0.6.2/asyncly/srvmocker/serialization/base.py +0 -9
- {asyncly-0.6.2 → asyncly-0.7.1}/.gitignore +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/handlers/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/handlers/base.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/sinks/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/timeout.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/typing.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/py.typed +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/assertions.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/constants.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/exceptions.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/handlers.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/matching.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/base.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/raw.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/sequence.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/serialization/__init__.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/serialization/json.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/serialization/msgpack.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/serialization/toml.py +0 -0
- {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/serialization/yaml.py +0 -0
asyncly-0.7.1/PKG-INFO
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: asyncly
|
|
3
|
+
Version: 0.7.1
|
|
4
|
+
Summary: Simple HTTP client and server for your integrations based on aiohttp
|
|
5
|
+
Project-URL: Homepage, https://github.com/andy-takker/asyncly
|
|
6
|
+
Project-URL: Documentation, https://andy-takker.github.io/asyncly/
|
|
7
|
+
Project-URL: Source, https://github.com/andy-takker/asyncly
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/andy-takker/asyncly/issues
|
|
9
|
+
Author-email: Sergey Natalenko <sergey.natalenko@mail.ru>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: aiohttp,client,http
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Framework :: Pytest
|
|
15
|
+
Classifier: Framework :: aiohttp
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: Information Technology
|
|
18
|
+
Classifier: Intended Audience :: System Administrators
|
|
19
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python
|
|
22
|
+
Classifier: Programming Language :: Python :: 3
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Topic :: Internet
|
|
28
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
29
|
+
Classifier: Topic :: Software Development
|
|
30
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
31
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
32
|
+
Requires-Python: <4,>=3.10
|
|
33
|
+
Requires-Dist: aiohttp<4,>=3.13.3
|
|
34
|
+
Provides-Extra: msgspec
|
|
35
|
+
Requires-Dist: msgspec<0.20,>=0.19.0; extra == 'msgspec'
|
|
36
|
+
Provides-Extra: opentelemetry
|
|
37
|
+
Requires-Dist: opentelemetry-sdk>=1.37.0; extra == 'opentelemetry'
|
|
38
|
+
Provides-Extra: orjson
|
|
39
|
+
Requires-Dist: orjson<4,>=3.10.6; extra == 'orjson'
|
|
40
|
+
Provides-Extra: prometheus
|
|
41
|
+
Requires-Dist: prometheus-client>=0.22.1; extra == 'prometheus'
|
|
42
|
+
Provides-Extra: pydantic
|
|
43
|
+
Requires-Dist: pydantic<3,>=2.8.2; extra == 'pydantic'
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
# Asyncly
|
|
47
|
+
|
|
48
|
+
[](https://pypi.org/project/asyncly/)
|
|
49
|
+
[](https://pypi.org/project/asyncly/)
|
|
50
|
+
[](https://pypi.org/project/asyncly/)
|
|
51
|
+
[](https://andy-takker.github.io/asyncly/)
|
|
52
|
+
|
|
53
|
+
**A tiny async HTTP client and a *real* aiohttp mock server for testing your integrations — built on [aiohttp](https://docs.aiohttp.org/).**
|
|
54
|
+
|
|
55
|
+
asyncly gives you two pieces that fit together:
|
|
56
|
+
|
|
57
|
+
- **`BaseHttpClient`** — a thin, typed base class for HTTP clients with per-status
|
|
58
|
+
response handlers, flexible timeouts, and first-class proxy support.
|
|
59
|
+
- **`srvmocker`** — spin up a *real* aiohttp test server (not a transport patch)
|
|
60
|
+
to simulate upstreams in tests, assert what your client sent, and even route
|
|
61
|
+
through a mock proxy.
|
|
62
|
+
|
|
63
|
+
📖 **[Read the full documentation →](https://andy-takker.github.io/asyncly/)**
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install asyncly
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Optional extras — `msgspec`, `pydantic`, `orjson`, `prometheus`, `opentelemetry`:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install "asyncly[pydantic]"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Quickstart
|
|
78
|
+
|
|
79
|
+
Define a client by subclassing `BaseHttpClient` and mapping status codes to handlers:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from http import HTTPStatus
|
|
83
|
+
from types import MappingProxyType
|
|
84
|
+
|
|
85
|
+
from aiohttp import ClientSession, hdrs
|
|
86
|
+
from pydantic import BaseModel
|
|
87
|
+
|
|
88
|
+
from asyncly import BaseHttpClient, DEFAULT_TIMEOUT, ResponseHandlersType
|
|
89
|
+
from asyncly.client.handlers.pydantic import parse_model
|
|
90
|
+
from asyncly.client.timeout import TimeoutType
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CatFact(BaseModel):
|
|
94
|
+
fact: str
|
|
95
|
+
length: int
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CatfactClient(BaseHttpClient):
|
|
99
|
+
FACT_HANDLERS: ResponseHandlersType = MappingProxyType(
|
|
100
|
+
{HTTPStatus.OK: parse_model(CatFact)}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async def fetch_fact(self, timeout: TimeoutType = DEFAULT_TIMEOUT) -> CatFact:
|
|
104
|
+
return await self._make_req(
|
|
105
|
+
method=hdrs.METH_GET,
|
|
106
|
+
url=self._url / "fact",
|
|
107
|
+
handlers=self.FACT_HANDLERS,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Test it against a real mock server — no network, no monkeypatching:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from asyncly.srvmocker import JsonResponse, MockRoute, start_service
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def test_fetch_fact() -> None:
|
|
119
|
+
routes = [MockRoute("GET", "/fact", "fact")]
|
|
120
|
+
async with start_service(routes) as service:
|
|
121
|
+
service.register("fact", JsonResponse({"fact": "Cats sleep a lot.", "length": 17}))
|
|
122
|
+
|
|
123
|
+
async with ClientSession() as session:
|
|
124
|
+
client = CatfactClient(url=service.url, session=session, client_name="catfact")
|
|
125
|
+
fact = await client.fetch_fact()
|
|
126
|
+
|
|
127
|
+
assert fact.fact == "Cats sleep a lot."
|
|
128
|
+
service.assert_called("fact", times=1)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Prefer fixtures over boilerplate? asyncly ships a pytest plugin with `mock_service`
|
|
132
|
+
and `mock_proxy`. See the [Quickstart](https://andy-takker.github.io/asyncly/) for more.
|
|
133
|
+
|
|
134
|
+
## Why asyncly?
|
|
135
|
+
|
|
136
|
+
Unlike transport-patching mocks (`aioresponses`, `respx`), `srvmocker` runs a real
|
|
137
|
+
`aiohttp.TestServer` inside your test loop — catching real sockets, timeouts, header
|
|
138
|
+
auto-injection, and serialization quirks. See
|
|
139
|
+
[Testing strategies](https://andy-takker.github.io/asyncly/) for the full comparison.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[MIT](https://github.com/andy-takker/asyncly/blob/master/LICENSE)
|
asyncly-0.7.1/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Asyncly
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/asyncly/)
|
|
4
|
+
[](https://pypi.org/project/asyncly/)
|
|
5
|
+
[](https://pypi.org/project/asyncly/)
|
|
6
|
+
[](https://andy-takker.github.io/asyncly/)
|
|
7
|
+
|
|
8
|
+
**A tiny async HTTP client and a *real* aiohttp mock server for testing your integrations — built on [aiohttp](https://docs.aiohttp.org/).**
|
|
9
|
+
|
|
10
|
+
asyncly gives you two pieces that fit together:
|
|
11
|
+
|
|
12
|
+
- **`BaseHttpClient`** — a thin, typed base class for HTTP clients with per-status
|
|
13
|
+
response handlers, flexible timeouts, and first-class proxy support.
|
|
14
|
+
- **`srvmocker`** — spin up a *real* aiohttp test server (not a transport patch)
|
|
15
|
+
to simulate upstreams in tests, assert what your client sent, and even route
|
|
16
|
+
through a mock proxy.
|
|
17
|
+
|
|
18
|
+
📖 **[Read the full documentation →](https://andy-takker.github.io/asyncly/)**
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install asyncly
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Optional extras — `msgspec`, `pydantic`, `orjson`, `prometheus`, `opentelemetry`:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install "asyncly[pydantic]"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
Define a client by subclassing `BaseHttpClient` and mapping status codes to handlers:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from http import HTTPStatus
|
|
38
|
+
from types import MappingProxyType
|
|
39
|
+
|
|
40
|
+
from aiohttp import ClientSession, hdrs
|
|
41
|
+
from pydantic import BaseModel
|
|
42
|
+
|
|
43
|
+
from asyncly import BaseHttpClient, DEFAULT_TIMEOUT, ResponseHandlersType
|
|
44
|
+
from asyncly.client.handlers.pydantic import parse_model
|
|
45
|
+
from asyncly.client.timeout import TimeoutType
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CatFact(BaseModel):
|
|
49
|
+
fact: str
|
|
50
|
+
length: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CatfactClient(BaseHttpClient):
|
|
54
|
+
FACT_HANDLERS: ResponseHandlersType = MappingProxyType(
|
|
55
|
+
{HTTPStatus.OK: parse_model(CatFact)}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def fetch_fact(self, timeout: TimeoutType = DEFAULT_TIMEOUT) -> CatFact:
|
|
59
|
+
return await self._make_req(
|
|
60
|
+
method=hdrs.METH_GET,
|
|
61
|
+
url=self._url / "fact",
|
|
62
|
+
handlers=self.FACT_HANDLERS,
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Test it against a real mock server — no network, no monkeypatching:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from asyncly.srvmocker import JsonResponse, MockRoute, start_service
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def test_fetch_fact() -> None:
|
|
74
|
+
routes = [MockRoute("GET", "/fact", "fact")]
|
|
75
|
+
async with start_service(routes) as service:
|
|
76
|
+
service.register("fact", JsonResponse({"fact": "Cats sleep a lot.", "length": 17}))
|
|
77
|
+
|
|
78
|
+
async with ClientSession() as session:
|
|
79
|
+
client = CatfactClient(url=service.url, session=session, client_name="catfact")
|
|
80
|
+
fact = await client.fetch_fact()
|
|
81
|
+
|
|
82
|
+
assert fact.fact == "Cats sleep a lot."
|
|
83
|
+
service.assert_called("fact", times=1)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Prefer fixtures over boilerplate? asyncly ships a pytest plugin with `mock_service`
|
|
87
|
+
and `mock_proxy`. See the [Quickstart](https://andy-takker.github.io/asyncly/) for more.
|
|
88
|
+
|
|
89
|
+
## Why asyncly?
|
|
90
|
+
|
|
91
|
+
Unlike transport-patching mocks (`aioresponses`, `respx`), `srvmocker` runs a real
|
|
92
|
+
`aiohttp.TestServer` inside your test loop — catching real sockets, timeouts, header
|
|
93
|
+
auto-injection, and serialization quirks. See
|
|
94
|
+
[Testing strategies](https://andy-takker.github.io/asyncly/) for the full comparison.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
[MIT](https://github.com/andy-takker/asyncly/blob/master/LICENSE)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from aiohttp import BasicAuth, ClientSession
|
|
4
|
+
from aiohttp.client import DEFAULT_TIMEOUT
|
|
5
|
+
from yarl import URL
|
|
6
|
+
|
|
7
|
+
from asyncly.client.handlers.base import (
|
|
8
|
+
ResponseHandlersType,
|
|
9
|
+
apply_handler,
|
|
10
|
+
)
|
|
11
|
+
from asyncly.client.timeout import TimeoutType, get_timeout
|
|
12
|
+
from asyncly.client.typing import MethodType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseHttpClient:
|
|
16
|
+
"""Typed base class for building async HTTP API clients.
|
|
17
|
+
|
|
18
|
+
Subclass it and add one method per endpoint, delegating to ``_make_req``
|
|
19
|
+
with a mapping of status codes to response handlers. The
|
|
20
|
+
`aiohttp.ClientSession` is injected, so connection pooling and lifecycle
|
|
21
|
+
stay under your control.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
```python
|
|
25
|
+
class CatfactClient(BaseHttpClient):
|
|
26
|
+
FACT_HANDLERS = MappingProxyType({HTTPStatus.OK: parse_model(CatFact)})
|
|
27
|
+
|
|
28
|
+
async def fetch_fact(self) -> CatFact:
|
|
29
|
+
return await self._make_req(
|
|
30
|
+
method=hdrs.METH_GET,
|
|
31
|
+
url=self._url / "fact",
|
|
32
|
+
handlers=self.FACT_HANDLERS,
|
|
33
|
+
)
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_url", "_session", "_client_name", "_proxy", "_proxy_auth")
|
|
38
|
+
|
|
39
|
+
_url: URL
|
|
40
|
+
_session: ClientSession
|
|
41
|
+
_client_name: str
|
|
42
|
+
_proxy: URL | None
|
|
43
|
+
_proxy_auth: BasicAuth | None
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
url: URL | str,
|
|
48
|
+
session: ClientSession,
|
|
49
|
+
client_name: str,
|
|
50
|
+
*,
|
|
51
|
+
proxy: URL | str | None = None,
|
|
52
|
+
proxy_auth: BasicAuth | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Initialize the client.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
url: Base URL the client's endpoints are resolved against.
|
|
58
|
+
session: The `aiohttp.ClientSession` to issue requests with. The
|
|
59
|
+
caller owns its lifecycle.
|
|
60
|
+
client_name: Identifier used in metrics labels and error messages.
|
|
61
|
+
proxy: Default proxy URL for every request. Can be overridden
|
|
62
|
+
per request by passing `proxy=` to `_make_req`.
|
|
63
|
+
proxy_auth: Default `BasicAuth` credentials for the proxy.
|
|
64
|
+
"""
|
|
65
|
+
self._url = url if isinstance(url, URL) else URL(url)
|
|
66
|
+
self._session = session
|
|
67
|
+
self._client_name = client_name
|
|
68
|
+
self._proxy = URL(proxy) if isinstance(proxy, str) else proxy
|
|
69
|
+
self._proxy_auth = proxy_auth
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def url(self) -> URL:
|
|
73
|
+
"""The base URL the client was configured with."""
|
|
74
|
+
return self._url
|
|
75
|
+
|
|
76
|
+
async def _make_req(
|
|
77
|
+
self,
|
|
78
|
+
method: MethodType,
|
|
79
|
+
url: URL,
|
|
80
|
+
handlers: ResponseHandlersType,
|
|
81
|
+
timeout: TimeoutType = DEFAULT_TIMEOUT,
|
|
82
|
+
**kwargs: Any,
|
|
83
|
+
) -> Any:
|
|
84
|
+
"""Issue a request and dispatch the response to a status handler.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
method: HTTP method, e.g. `aiohttp.hdrs.METH_GET`.
|
|
88
|
+
url: Fully resolved request URL.
|
|
89
|
+
handlers: Mapping of status code (exact, ``"2xx"`` range, or ``"*"``
|
|
90
|
+
wildcard) to a response handler callable.
|
|
91
|
+
timeout: Per-request timeout; accepts `ClientTimeout`, `timedelta`,
|
|
92
|
+
or a number of seconds.
|
|
93
|
+
**kwargs: Extra arguments forwarded to `ClientSession.request`
|
|
94
|
+
(e.g. ``json``, ``params``, ``headers``). Instance-level
|
|
95
|
+
``proxy`` / ``proxy_auth`` are injected here unless overridden.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Whatever the matched handler returns.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
UnhandledStatusException: If no handler matches the response status.
|
|
102
|
+
"""
|
|
103
|
+
if "proxy" not in kwargs and self._proxy is not None:
|
|
104
|
+
kwargs["proxy"] = self._proxy
|
|
105
|
+
if "proxy_auth" not in kwargs and self._proxy_auth is not None:
|
|
106
|
+
kwargs["proxy_auth"] = self._proxy_auth
|
|
107
|
+
async with self._session.request(
|
|
108
|
+
method=method,
|
|
109
|
+
url=url,
|
|
110
|
+
timeout=get_timeout(timeout),
|
|
111
|
+
**kwargs,
|
|
112
|
+
) as response:
|
|
113
|
+
return await apply_handler(
|
|
114
|
+
handlers=handlers,
|
|
115
|
+
response=response,
|
|
116
|
+
client_name=self._client_name,
|
|
117
|
+
)
|
|
@@ -3,10 +3,18 @@ from yarl import URL
|
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
class BaseHttpClientException(ClientError):
|
|
6
|
-
|
|
6
|
+
"""Base class for exceptions raised by `BaseHttpClient`."""
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class UnhandledStatusException(BaseHttpClientException, KeyError):
|
|
10
|
+
"""Raised when a response status has no matching handler.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
status: The unmatched response status code.
|
|
14
|
+
url: The request URL.
|
|
15
|
+
client_name: The originating client's name, if known.
|
|
16
|
+
"""
|
|
17
|
+
|
|
10
18
|
status: int
|
|
11
19
|
url: URL
|
|
12
20
|
client_name: str | None
|
|
@@ -14,6 +14,18 @@ def parse_json(
|
|
|
14
14
|
parser: Callable,
|
|
15
15
|
loads: Callable = json.loads,
|
|
16
16
|
) -> Callable[[ClientResponse], Awaitable[Any]]:
|
|
17
|
+
"""Build a response handler that decodes JSON and passes it to ``parser``.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
parser: Callable applied to the decoded JSON. May be sync or async.
|
|
21
|
+
Use ``lambda data: data`` to return the parsed value unchanged.
|
|
22
|
+
loads: JSON loader. Defaults to `orjson.loads` when the ``orjson``
|
|
23
|
+
extra is installed, otherwise the stdlib `json.loads`.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
An async handler usable as a value in a response-handlers mapping.
|
|
27
|
+
"""
|
|
28
|
+
|
|
17
29
|
async def _parse(response: ClientResponse) -> Any:
|
|
18
30
|
response_data = await response.json(loads=loads)
|
|
19
31
|
if iscoroutinefunction(parser):
|
|
@@ -29,6 +29,22 @@ def parse_struct(
|
|
|
29
29
|
data_format: DataFormat = "json",
|
|
30
30
|
strict: bool = True,
|
|
31
31
|
) -> Callable[[ClientResponse], Awaitable[T]]:
|
|
32
|
+
"""Build a response handler that decodes the body into a msgspec struct.
|
|
33
|
+
|
|
34
|
+
Requires the ``msgspec`` extra.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
struct: The `msgspec.Struct` subclass to decode into.
|
|
38
|
+
data_format: Wire format of the body: ``"json"``, ``"msgpack"``,
|
|
39
|
+
``"toml"``, or ``"yaml"``.
|
|
40
|
+
strict: Pass-through to msgspec strict decoding (no implicit coercion).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
An async handler usable as a value in a response-handlers mapping.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
msgspec.ValidationError: If the payload does not match ``struct``.
|
|
47
|
+
"""
|
|
32
48
|
decode = _choose_decoder(data_format)
|
|
33
49
|
|
|
34
50
|
async def _parse(response: ClientResponse) -> T:
|
|
@@ -8,6 +8,20 @@ T = TypeVar("T", bound=BaseModel)
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def parse_model(model: type[T]) -> Callable[[ClientResponse], Awaitable[T]]:
|
|
11
|
+
"""Build a response handler that validates the body into a Pydantic model.
|
|
12
|
+
|
|
13
|
+
Requires the ``pydantic`` extra.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
model: The `pydantic.BaseModel` subclass to validate the JSON body into.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
An async handler usable as a value in a response-handlers mapping.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
pydantic.ValidationError: If the body does not match the model.
|
|
23
|
+
"""
|
|
24
|
+
|
|
11
25
|
async def _parse(response: ClientResponse) -> T:
|
|
12
26
|
return model.model_validate_json(await response.read())
|
|
13
27
|
|
|
@@ -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,32 +16,61 @@ from asyncly.client.typing import ResponseHandler, ResponseHandlersType, RouteRe
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class InstrumentableHttpClient(BaseHttpClient):
|
|
19
|
-
|
|
19
|
+
"""`BaseHttpClient` that records request metrics through a pluggable sink.
|
|
20
|
+
|
|
21
|
+
Behaves exactly like [`BaseHttpClient`][asyncly.BaseHttpClient] until a sink
|
|
22
|
+
is enabled. Each completed request reports its client name, method, resolved
|
|
23
|
+
route, status, duration, and error type to the active
|
|
24
|
+
[`MetricsSink`][asyncly.client.metrics.sinks.base.MetricsSink].
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
__slots__ = ("_metrics_sink", "_resolve_route")
|
|
20
28
|
|
|
21
29
|
def __init__(
|
|
22
30
|
self,
|
|
23
31
|
url: URL | str,
|
|
24
32
|
session: ClientSession,
|
|
25
33
|
client_name: str,
|
|
34
|
+
*,
|
|
35
|
+
proxy: URL | str | None = None,
|
|
36
|
+
proxy_auth: BasicAuth | None = None,
|
|
26
37
|
) -> None:
|
|
27
|
-
super().__init__(
|
|
38
|
+
super().__init__(
|
|
39
|
+
url=url,
|
|
40
|
+
session=session,
|
|
41
|
+
client_name=client_name,
|
|
42
|
+
proxy=proxy,
|
|
43
|
+
proxy_auth=proxy_auth,
|
|
44
|
+
)
|
|
28
45
|
self._metrics_sink: MetricsSink = NoopSink()
|
|
29
46
|
self._resolve_route: RouteResolver = default_route_resolver
|
|
30
47
|
|
|
31
48
|
def enable_metrics(
|
|
32
49
|
self, sink: MetricsSink, *, route_resolver: RouteResolver | None = None
|
|
33
50
|
) -> None:
|
|
51
|
+
"""Start emitting metrics to ``sink``.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
sink: The metrics sink to report each request to.
|
|
55
|
+
route_resolver: Optional override for how request URLs are
|
|
56
|
+
normalized into low-cardinality route labels.
|
|
57
|
+
"""
|
|
34
58
|
self._metrics_sink = sink
|
|
35
59
|
if route_resolver is not None:
|
|
36
60
|
self._resolve_route = route_resolver
|
|
37
61
|
|
|
38
62
|
def disable_metrics(self) -> None:
|
|
63
|
+
"""Stop emitting metrics (revert to the no-op sink)."""
|
|
39
64
|
self._metrics_sink = NoopSink()
|
|
40
65
|
self._resolve_route = default_route_resolver
|
|
41
66
|
|
|
42
67
|
def instrument( # type: ignore[no-untyped-def]
|
|
43
68
|
self, sink: MetricsSink, *, route_resolver: RouteResolver | None = None
|
|
44
69
|
):
|
|
70
|
+
"""Context manager that enables ``sink`` for the duration of a block.
|
|
71
|
+
|
|
72
|
+
Restores the previous sink and route resolver on exit.
|
|
73
|
+
"""
|
|
45
74
|
client = self
|
|
46
75
|
|
|
47
76
|
class _Ctx:
|
|
@@ -2,6 +2,12 @@ from yarl import URL
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
def default_route_resolver(url: URL) -> str:
|
|
5
|
+
"""Normalize a URL path into a low-cardinality route label.
|
|
6
|
+
|
|
7
|
+
Numeric and UUID-like path segments are replaced with ``:id`` so that, for
|
|
8
|
+
example, ``/cats/42`` and ``/cats/7`` both map to ``/cats/:id``. Used as the
|
|
9
|
+
default route label for client metrics.
|
|
10
|
+
"""
|
|
5
11
|
parts: list[str] = []
|
|
6
12
|
for p in url.path.split("/"):
|
|
7
13
|
if not p:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MetricsSink(Protocol):
|
|
5
|
+
"""Protocol for metrics backends used by `InstrumentableHttpClient`.
|
|
6
|
+
|
|
7
|
+
Implement `observe_request` to record requests in any backend.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def observe_request(
|
|
11
|
+
self,
|
|
12
|
+
*,
|
|
13
|
+
client: str,
|
|
14
|
+
method: str,
|
|
15
|
+
route: str,
|
|
16
|
+
status: int | str,
|
|
17
|
+
duration_seconds: float,
|
|
18
|
+
error_type: str | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Record a single completed request.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
client: The client name (`client_name`).
|
|
24
|
+
method: HTTP method.
|
|
25
|
+
route: Normalized, low-cardinality route label.
|
|
26
|
+
status: Response status code, or a string marker on error.
|
|
27
|
+
duration_seconds: Total time including response handling.
|
|
28
|
+
error_type: Exception class name if the request failed, else None.
|
|
29
|
+
"""
|
|
30
|
+
...
|
|
@@ -2,6 +2,14 @@ from opentelemetry.metrics import Meter
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
class OpenTelemetrySink:
|
|
5
|
+
"""Metrics sink backed by OpenTelemetry (needs the ``opentelemetry`` extra).
|
|
6
|
+
|
|
7
|
+
Records request counts, durations, and errors through the given `Meter`.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
meter: An OpenTelemetry `Meter` to create instruments from.
|
|
11
|
+
"""
|
|
12
|
+
|
|
5
13
|
def __init__(self, meter: Meter) -> None:
|
|
6
14
|
# Counter: количество
|
|
7
15
|
self._req_counter = meter.create_counter(
|
|
@@ -5,6 +5,18 @@ from prometheus_client.registry import REGISTRY, CollectorRegistry
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class PrometheusSink:
|
|
8
|
+
"""Metrics sink that records to Prometheus (needs the ``prometheus`` extra).
|
|
9
|
+
|
|
10
|
+
Exposes a request-duration histogram and a request counter, labeled by
|
|
11
|
+
client, method, route, and status.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
namespace: Prometheus metric namespace prefix.
|
|
15
|
+
subsystem: Prometheus metric subsystem prefix.
|
|
16
|
+
buckets: Histogram bucket boundaries in seconds.
|
|
17
|
+
registry: Collector registry to register the metrics on.
|
|
18
|
+
"""
|
|
19
|
+
|
|
8
20
|
def __init__(
|
|
9
21
|
self,
|
|
10
22
|
namespace: str = "asyncly",
|
|
@@ -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
|
)
|