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.
Files changed (55) hide show
  1. asyncly-0.7.1/PKG-INFO +143 -0
  2. asyncly-0.7.1/README.md +98 -0
  3. asyncly-0.7.1/asyncly/client/base.py +117 -0
  4. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/handlers/exceptions.py +9 -1
  5. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/handlers/json.py +12 -0
  6. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/handlers/msgspec.py +16 -0
  7. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/handlers/pydantic.py +14 -0
  8. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/instrumentable_client.py +32 -3
  9. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/route_resolver.py +6 -0
  10. asyncly-0.7.1/asyncly/client/metrics/sinks/base.py +30 -0
  11. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/sinks/noop.py +1 -1
  12. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/sinks/opentelemetry.py +8 -0
  13. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/sinks/prometheus.py +12 -0
  14. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/pytest_plugin.py +10 -0
  15. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/__init__.py +3 -0
  16. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/models.py +64 -2
  17. asyncly-0.7.1/asyncly/srvmocker/proxy.py +203 -0
  18. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/content.py +13 -0
  19. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/json.py +8 -0
  20. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/msgpack.py +2 -0
  21. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/timeout.py +7 -0
  22. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/toml.py +2 -0
  23. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/yaml.py +2 -0
  24. asyncly-0.7.1/asyncly/srvmocker/serialization/base.py +19 -0
  25. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/service.py +22 -0
  26. {asyncly-0.6.2 → asyncly-0.7.1}/pyproject.toml +9 -3
  27. asyncly-0.6.2/PKG-INFO +0 -409
  28. asyncly-0.6.2/README.rst +0 -365
  29. asyncly-0.6.2/asyncly/client/base.py +0 -51
  30. asyncly-0.6.2/asyncly/client/metrics/sinks/base.py +0 -14
  31. asyncly-0.6.2/asyncly/srvmocker/serialization/base.py +0 -9
  32. {asyncly-0.6.2 → asyncly-0.7.1}/.gitignore +0 -0
  33. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/__init__.py +0 -0
  34. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/__init__.py +0 -0
  35. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/handlers/__init__.py +0 -0
  36. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/handlers/base.py +0 -0
  37. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/__init__.py +0 -0
  38. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/metrics/sinks/__init__.py +0 -0
  39. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/timeout.py +0 -0
  40. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/client/typing.py +0 -0
  41. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/py.typed +0 -0
  42. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/assertions.py +0 -0
  43. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/constants.py +0 -0
  44. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/exceptions.py +0 -0
  45. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/handlers.py +0 -0
  46. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/matching.py +0 -0
  47. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/__init__.py +0 -0
  48. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/base.py +0 -0
  49. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/raw.py +0 -0
  50. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/responses/sequence.py +0 -0
  51. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/serialization/__init__.py +0 -0
  52. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/serialization/json.py +0 -0
  53. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/serialization/msgpack.py +0 -0
  54. {asyncly-0.6.2 → asyncly-0.7.1}/asyncly/srvmocker/serialization/toml.py +0 -0
  55. {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
+ [![PyPI version](https://img.shields.io/pypi/v/asyncly.svg)](https://pypi.org/project/asyncly/)
49
+ [![Python versions](https://img.shields.io/pypi/pyversions/asyncly.svg)](https://pypi.org/project/asyncly/)
50
+ [![License](https://img.shields.io/pypi/l/asyncly.svg)](https://pypi.org/project/asyncly/)
51
+ [![Documentation](https://img.shields.io/badge/docs-mkdocs--material-blue.svg)](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)
@@ -0,0 +1,98 @@
1
+ # Asyncly
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/asyncly.svg)](https://pypi.org/project/asyncly/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/asyncly.svg)](https://pypi.org/project/asyncly/)
5
+ [![License](https://img.shields.io/pypi/l/asyncly.svg)](https://pypi.org/project/asyncly/)
6
+ [![Documentation](https://img.shields.io/badge/docs-mkdocs--material-blue.svg)](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
- pass
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
- __slots__ = ("_metrics_sink", "_resolve_route") + BaseHttpClient.__slots__
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__(url=url, session=session, client_name=client_name)
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
+ ...
@@ -1,5 +1,5 @@
1
1
  class NoopSink:
2
- """Синк по умолчанию: ничего не делает."""
2
+ """The default sink: records nothing and adds no overhead."""
3
3
 
4
4
  def observe_request(
5
5
  self,
@@ -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
  )