asyncly 0.3.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from aiohttp.client import DEFAULT_TIMEOUT
2
+
3
+ from asyncly.client.base import BaseHttpClient
4
+ from asyncly.client.handlers.base import ResponseHandlersType
5
+ from asyncly.client.timeout import TimeoutType
6
+
7
+ __all__ = (
8
+ "BaseHttpClient",
9
+ "TimeoutType",
10
+ "ResponseHandlersType",
11
+ "DEFAULT_TIMEOUT",
12
+ )
File without changes
asyncly/client/base.py ADDED
@@ -0,0 +1,50 @@
1
+ from typing import Any, Literal
2
+
3
+ from aiohttp import 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
+
13
+
14
+ class BaseHttpClient:
15
+ __slots__ = ("_url", "_session", "_client_name")
16
+
17
+ _url: URL
18
+ _session: ClientSession
19
+ _client_name: str
20
+
21
+ def __init__(
22
+ self, url: URL | str, session: ClientSession, client_name: str
23
+ ) -> None:
24
+ self._url = url if isinstance(url, URL) else URL(url)
25
+ self._session = session
26
+ self._client_name = client_name
27
+
28
+ @property
29
+ def url(self) -> URL:
30
+ return self._url
31
+
32
+ async def _make_req(
33
+ self,
34
+ method: Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"],
35
+ url: URL,
36
+ handlers: ResponseHandlersType,
37
+ timeout: TimeoutType = DEFAULT_TIMEOUT,
38
+ **kwargs: Any,
39
+ ) -> Any:
40
+ async with self._session.request(
41
+ method=method,
42
+ url=url,
43
+ timeout=get_timeout(timeout),
44
+ **kwargs,
45
+ ) as response:
46
+ return await apply_handler(
47
+ handlers=handlers,
48
+ response=response,
49
+ client_name=self._client_name,
50
+ )
File without changes
@@ -0,0 +1,39 @@
1
+ from collections.abc import Callable, Mapping
2
+ from http import HTTPStatus
3
+ from typing import Any
4
+
5
+ from aiohttp import ClientResponse
6
+
7
+ from asyncly.client.handlers.exceptions import UnhandledStatusException
8
+
9
+ ResponseHandlersType = Mapping[HTTPStatus | int | str, Callable]
10
+
11
+
12
+ async def apply_handler(
13
+ handlers: ResponseHandlersType,
14
+ response: ClientResponse,
15
+ client_name: str,
16
+ ) -> Any:
17
+ handler = _find_handler(handlers=handlers, status=response.status)
18
+ if not handler:
19
+ raise UnhandledStatusException(
20
+ f"Unexpected resposne {response.status} from {response.url}",
21
+ status=response.status,
22
+ url=response.url,
23
+ client_name=client_name,
24
+ )
25
+ return await handler(response=response)
26
+
27
+
28
+ def _find_handler(handlers: ResponseHandlersType, status: int) -> Callable | None:
29
+ if status in handlers:
30
+ return handlers[status]
31
+
32
+ status_group = f"{status // 100}xx"
33
+ if status_group in handlers:
34
+ return handlers[status_group]
35
+
36
+ if "*" in handlers:
37
+ return handlers["*"]
38
+
39
+ return None
@@ -0,0 +1,24 @@
1
+ from aiohttp import ClientError
2
+ from yarl import URL
3
+
4
+
5
+ class BaseHttpClientException(ClientError):
6
+ pass
7
+
8
+
9
+ class UnhandledStatusException(BaseHttpClientException, KeyError):
10
+ status: int
11
+ url: URL
12
+ client_name: str | None
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ status: int,
18
+ url: URL,
19
+ client_name: str | None = None,
20
+ ):
21
+ super().__init__(message)
22
+ self.status = status
23
+ self.url = url
24
+ self.client_name = client_name
@@ -0,0 +1,24 @@
1
+ from asyncio import iscoroutinefunction
2
+ from collections.abc import Awaitable, Callable
3
+ from typing import Any
4
+
5
+ from aiohttp import ClientResponse
6
+
7
+ try:
8
+ import orjson as json
9
+ except ImportError:
10
+ import json # type: ignore
11
+
12
+
13
+ def parse_json(
14
+ parser: Callable,
15
+ loads: Callable = json.loads,
16
+ ) -> Callable[[ClientResponse], Awaitable[Any]]:
17
+ async def _parse(response: ClientResponse) -> Any:
18
+ response_data = await response.json(loads=loads)
19
+ if iscoroutinefunction(parser):
20
+ return await parser(response_data)
21
+ else:
22
+ return parser(response_data)
23
+
24
+ return _parse
@@ -0,0 +1,15 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from typing import TypeVar
3
+
4
+ from aiohttp import ClientResponse
5
+ from msgspec import Struct
6
+ from msgspec.json import decode
7
+
8
+ T = TypeVar("T", bound=Struct)
9
+
10
+
11
+ def parse_struct(struct: type[T]) -> Callable[[ClientResponse], Awaitable[T]]:
12
+ async def _parse(response: ClientResponse) -> T:
13
+ return decode(await response.read(), type=struct)
14
+
15
+ return _parse
@@ -0,0 +1,14 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from typing import TypeVar
3
+
4
+ from aiohttp import ClientResponse
5
+ from pydantic import BaseModel
6
+
7
+ T = TypeVar("T", bound=BaseModel)
8
+
9
+
10
+ def parse_model(model: type[T]) -> Callable[[ClientResponse], Awaitable[T]]:
11
+ async def _parse(response: ClientResponse) -> T:
12
+ return model.model_validate_json(await response.read())
13
+
14
+ return _parse
@@ -0,0 +1,27 @@
1
+ from datetime import timedelta
2
+ from functools import singledispatch
3
+
4
+ from aiohttp import ClientTimeout
5
+
6
+ TimeoutType = ClientTimeout | timedelta | int | float
7
+
8
+
9
+ @singledispatch
10
+ def get_timeout(t: TimeoutType) -> ClientTimeout:
11
+ raise TypeError(f"Unknown type {type(t)}")
12
+
13
+
14
+ @get_timeout.register(ClientTimeout)
15
+ def _client_timeout(t: ClientTimeout) -> ClientTimeout:
16
+ return t
17
+
18
+
19
+ @get_timeout.register(timedelta)
20
+ def _timedelta(t: timedelta) -> ClientTimeout:
21
+ return ClientTimeout(total=t.total_seconds())
22
+
23
+
24
+ @get_timeout.register(int)
25
+ @get_timeout.register(float)
26
+ def _number(t: int | float) -> ClientTimeout:
27
+ return ClientTimeout(total=float(t))
asyncly/py.typed ADDED
File without changes
@@ -0,0 +1,10 @@
1
+ from asyncly.srvmocker.models import MockRoute, MockService
2
+ from asyncly.srvmocker.responses import JsonResponse
3
+ from asyncly.srvmocker.service import start_service
4
+
5
+ __all__ = (
6
+ "MockRoute",
7
+ "MockService",
8
+ "JsonResponse",
9
+ "start_service",
10
+ )
@@ -0,0 +1,21 @@
1
+ from collections.abc import Awaitable, Callable
2
+
3
+ from aiohttp.web_request import Request
4
+ from aiohttp.web_response import Response
5
+
6
+ from asyncly.srvmocker.models import MockService, RequestHistory
7
+
8
+
9
+ def get_default_handler(handler_name: str) -> Callable[[Request], Awaitable[Response]]:
10
+ async def _handler(request: Request) -> Response:
11
+ history = RequestHistory(
12
+ request=request,
13
+ body=await request.read(),
14
+ )
15
+ context: MockService = request.app["service"]
16
+ context.history.append(history)
17
+ context.history_map[handler_name].append(history)
18
+ handler = context.handlers[handler_name]
19
+ return await handler.response(request)
20
+
21
+ return _handler
@@ -0,0 +1,37 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import MutableMapping, MutableSequence
3
+ from dataclasses import dataclass
4
+
5
+ from aiohttp.web_request import Request
6
+ from aiohttp.web_response import Response
7
+ from yarl import URL
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class MockRoute:
12
+ method: str
13
+ path: str
14
+ handler_name: str
15
+
16
+
17
+ @dataclass
18
+ class RequestHistory:
19
+ request: Request
20
+ body: bytes
21
+
22
+
23
+ class BaseMockResponse(ABC):
24
+ @abstractmethod
25
+ async def response(self, request: Request) -> Response:
26
+ pass
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class MockService:
31
+ history: MutableSequence[RequestHistory]
32
+ history_map: MutableMapping[str, MutableSequence[RequestHistory]]
33
+ url: URL
34
+ handlers: MutableMapping[str, BaseMockResponse]
35
+
36
+ def register(self, name: str, resp: BaseMockResponse) -> None:
37
+ self.handlers[name] = resp
@@ -0,0 +1,80 @@
1
+ from asyncio import sleep
2
+ from collections.abc import Iterable, Iterator, Mapping, MutableMapping
3
+ from dataclasses import dataclass
4
+ from http import HTTPStatus
5
+ from typing import Any
6
+
7
+ from aiohttp import hdrs
8
+ from aiohttp.web_request import Request
9
+ from aiohttp.web_response import Response
10
+
11
+ from asyncly.srvmocker.models import BaseMockResponse
12
+ from asyncly.srvmocker.serialization import JsonSerializer, Serializer
13
+
14
+ TimeoutType = int | float
15
+
16
+
17
+ @dataclass
18
+ class ContentResponse(BaseMockResponse):
19
+ body: Any = None
20
+ status: int = HTTPStatus.OK
21
+ headers: Mapping[str, str] | None = None
22
+ serializer: Serializer | None = None
23
+
24
+ async def response(self, request: Request) -> Response:
25
+ headers: MutableMapping[str, str] = dict()
26
+ if self.headers:
27
+ headers.update(self.headers)
28
+ if self.serializer:
29
+ headers[hdrs.CONTENT_TYPE] = self.serializer.content_type
30
+ return Response(
31
+ status=self.status,
32
+ body=self.serialize(),
33
+ headers=headers,
34
+ )
35
+
36
+ def serialize(self) -> Any:
37
+ if not self.serializer:
38
+ return self.body
39
+ return self.serializer.dumps(self.body)
40
+
41
+
42
+ @dataclass
43
+ class LatencyResponse(BaseMockResponse):
44
+ wrapped: BaseMockResponse
45
+ latency: TimeoutType
46
+
47
+ async def response(self, request: Request) -> Response:
48
+ await sleep(self.latency)
49
+ return await self.wrapped.response(request)
50
+
51
+
52
+ class MockSeqResponse(BaseMockResponse):
53
+ responses: Iterator[BaseMockResponse]
54
+
55
+ def __init__(self, responses: Iterable[BaseMockResponse]) -> None:
56
+ self.responses = iter(responses)
57
+
58
+ async def response(self, request: Request) -> Response:
59
+ resp = next(self.responses)
60
+ return await resp.response(request)
61
+
62
+
63
+ class JsonResponse(BaseMockResponse):
64
+ __content: ContentResponse
65
+
66
+ def __init__(
67
+ self,
68
+ body: Any,
69
+ status: int = HTTPStatus.OK,
70
+ headers: Mapping[str, str] | None = None,
71
+ ) -> None:
72
+ self.__content = ContentResponse(
73
+ body=body,
74
+ status=status,
75
+ headers=headers,
76
+ serializer=JsonSerializer,
77
+ )
78
+
79
+ async def response(self, request: Request) -> Response:
80
+ return await self.__content.response(request)
@@ -0,0 +1,16 @@
1
+ import json
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass
4
+ from typing import Any, Final
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Serializer:
9
+ dumps: Callable[[Any], str]
10
+ content_type: str
11
+
12
+
13
+ JsonSerializer: Final = Serializer(
14
+ dumps=json.dumps,
15
+ content_type="application/json",
16
+ )
@@ -0,0 +1,35 @@
1
+ from collections import defaultdict
2
+ from collections.abc import AsyncGenerator, Iterable
3
+ from contextlib import asynccontextmanager
4
+
5
+ from aiohttp.test_utils import TestServer
6
+ from aiohttp.web_app import Application
7
+
8
+ from asyncly.srvmocker.handlers import get_default_handler
9
+ from asyncly.srvmocker.models import MockRoute, MockService
10
+
11
+
12
+ @asynccontextmanager
13
+ async def start_service(
14
+ routes: Iterable[MockRoute],
15
+ ) -> AsyncGenerator[MockService, None]:
16
+ app = Application()
17
+ server = TestServer(app)
18
+ for route in routes:
19
+ app.router.add_route(
20
+ method=route.method,
21
+ path=route.path,
22
+ handler=get_default_handler(route.handler_name),
23
+ )
24
+ await server.start_server()
25
+ mock_service = MockService(
26
+ history=list(),
27
+ history_map=defaultdict(list),
28
+ url=server.make_url(""),
29
+ handlers=dict(),
30
+ )
31
+ app["service"] = mock_service
32
+ try:
33
+ yield mock_service
34
+ finally:
35
+ await server.close()
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.1
2
+ Name: asyncly
3
+ Version: 0.3.0
4
+ Summary: Simple HTTP client and server for your integrations based on aiohttp
5
+ Home-page: https://github.com/andy-takker/asyncly
6
+ License: MIT
7
+ Keywords: aiohttp,http,client
8
+ Author: Sergey Natalenko
9
+ Author-email: sergey.natalenko@mail.ru
10
+ Requires-Python: >=3.10,<4.0
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Framework :: Pytest
14
+ Classifier: Framework :: aiohttp
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Information Technology
17
+ Classifier: Intended Audience :: System Administrators
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Topic :: Internet
27
+ Classifier: Topic :: Internet :: WWW/HTTP
28
+ Classifier: Topic :: Software Development
29
+ Classifier: Topic :: Software Development :: Libraries
30
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
31
+ Provides-Extra: msgspec
32
+ Provides-Extra: orjson
33
+ Provides-Extra: pydantic
34
+ Requires-Dist: aiohttp (>=3.9.5,<4.0.0)
35
+ Requires-Dist: msgspec (>=0.18.6,<0.19.0) ; extra == "msgspec"
36
+ Requires-Dist: orjson (>=3.10.6,<4.0.0) ; extra == "orjson"
37
+ Requires-Dist: pydantic (>=2.8.2,<3.0.0) ; extra == "pydantic"
38
+ Project-URL: Bug Tracker, https://github.com/andy-takker/v/issues
39
+ Project-URL: Source, https://github.com/andy-takker/asyncly
40
+ Description-Content-Type: text/x-rst
41
+
42
+ Asyncly
43
+ =======
44
+
45
+ .. image:: https://img.shields.io/pypi/v/asyncly.svg
46
+ :target: https://pypi.python.org/pypi/asyncly/
47
+ :alt: Latest Version
48
+
49
+ .. image:: https://img.shields.io/pypi/wheel/base-http-client.svg
50
+ :target: https://pypi.python.org/pypi/base-http-client/
51
+
52
+ .. image:: https://img.shields.io/pypi/pyversions/base-http-client.svg
53
+ :target: https://pypi.python.org/pypi/base-http-client/
54
+
55
+ .. image:: https://img.shields.io/pypi/l/base-http-client.svg
56
+ :target: https://pypi.python.org/pypi/base-http-client/
57
+
58
+ Simple HTTP client and server for your integrations based on aiohttp_.
59
+
60
+ Installation
61
+ ------------
62
+
63
+ Installation is possible in standard ways, such as PyPI or
64
+ installation from a git repository directly.
65
+
66
+ Installing from PyPI_:
67
+
68
+ .. code-block:: bash
69
+
70
+ pip install asyncly
71
+
72
+ Installing from github.com:
73
+
74
+ .. code-block:: bash
75
+
76
+ pip install git+https://github.com/andy-takker/asyncly
77
+
78
+ The package contains several extras and you can install additional dependencies
79
+ if you specify them in this way.
80
+
81
+ For example, with msgspec_:
82
+
83
+ .. code-block:: bash
84
+
85
+ pip install "asyncly[msgspec]"
86
+
87
+ Complete table of extras below:
88
+
89
+ +-------------------------------------+----------------------------------+
90
+ | example | description |
91
+ +========================================================================+
92
+ | ``pip install "asyncly[msgspec]"`` | For using msgspec_ structs |
93
+ +-------------------------------------+----------------------------------+
94
+ | ``pip install "asyncly[orjson]"`` | For fast parsing json by orjson_ |
95
+ +-------------------------------------+----------------------------------+
96
+ | ``pip install "asyncly[pydantic]"`` | For using pydantic_ models |
97
+ +-------------------------------------+----------------------------------+
98
+
99
+ Quick start guide
100
+ -----------------
101
+
102
+ BaseHttpClient
103
+ ~~~~~~~~~~~~~~
104
+
105
+ Simple HTTP Client for `https://catfact.ninja`. See full example in `examples/catfact_client.py`_
106
+
107
+ .. code-block:: python
108
+
109
+ from base_http_client.client import (
110
+ DEFAULT_TIMEOUT,
111
+ BaseHttpClient,
112
+ ResponseHandlersType,
113
+ )
114
+ from base_http_client.handlers.pydantic import parse_model
115
+ from base_http_client.timeout import TimeoutType
116
+
117
+
118
+ class CatfactClient(BaseHttpClient):
119
+ RANDOM_CATFACT_HANDLERS: ResponseHandlersType = MappingProxyType(
120
+ {
121
+ HTTPStatus.OK: parse_model(CatfactSchema),
122
+ }
123
+ )
124
+
125
+ async def fetch_random_cat_fact(
126
+ self,
127
+ timeout: TimeoutType = DEFAULT_TIMEOUT,
128
+ ) -> CatfactSchema:
129
+ return await self._make_req(
130
+ method=hdrs.METH_GET,
131
+ url=self._url / "fact",
132
+ handlers=self.RANDOM_CATFACT_HANDLERS,
133
+ timeout=timeout,
134
+ )
135
+
136
+
137
+
138
+
139
+ .. _PyPI: https://pypi.org/
140
+ .. _aiohttp: https://pypi.org/project/aiohttp/
141
+ .. _msgspec: https://github.com/jcrist/msgspec
142
+ .. _orjson: https://github.com/ijl/orjson
143
+ .. _pydantic: https://github.com/pydantic/pydantic
144
+
145
+ .. _examples/catfact_client.py: https://github.com/andy-takker/asyncly/blob/master/examples/catfact_client.py
@@ -0,0 +1,20 @@
1
+ asyncly/__init__.py,sha256=mOpQzL75Op1HdNqT_o4oZ7NEpBCDARBh5hM-z5ExW1k,307
2
+ asyncly/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ asyncly/client/base.py,sha256=MMnyoybfdANA4e-F69j5N5vFZdcvSjKJPVH0MSyFrjI,1334
4
+ asyncly/client/handlers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ asyncly/client/handlers/base.py,sha256=SeAF9Q09OG37s4IYmuAqSWABShuzLXq4raNn1if6xnU,1086
6
+ asyncly/client/handlers/exceptions.py,sha256=mkjQwe_MSVDWT1QyvTB98uO3ZRJiF62i4TdJOMUOyzU,500
7
+ asyncly/client/handlers/json.py,sha256=RatnbI-5s8vRCnA6TOChz53Ev4NBWp5pVRSOqehb0LQ,627
8
+ asyncly/client/handlers/msgspec.py,sha256=k4x8_XTfNTxBIOR3LaZ8CcJcMW-2b5zf0SFupDuyD3k,413
9
+ asyncly/client/handlers/pydantic.py,sha256=U7TPuueJm5j4tTQT0n5dIhPCBxkDNJScWkF8qemHCg0,392
10
+ asyncly/client/timeout.py,sha256=hDPJ8iV5jXWJow5aK2A4iEQCIqagvVnhKa35gIVpKKM,658
11
+ asyncly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ asyncly/srvmocker/__init__.py,sha256=kUHXbFjbx9Mlu4qKPd3AAyCOLPkQMoME79NO76LBqC8,257
13
+ asyncly/srvmocker/handlers.py,sha256=I4PnFsvM_IVOI_Ud5eLi0xttObQLlTGTq2uuhCrSuNw,722
14
+ asyncly/srvmocker/models.py,sha256=ikVnVCTKGPS6zivesYFVJESVMwP9KiOkR4-qptiGbs4,853
15
+ asyncly/srvmocker/responses.py,sha256=AKuA7t0Iam_rS-gx78C_kknPWou2QKF4A4i9P2esecA,2291
16
+ asyncly/srvmocker/serialization.py,sha256=HteBp_w3XELlIvWEv2SnkntTY6IGEefoSeHajHqXmXs,310
17
+ asyncly/srvmocker/service.py,sha256=XRVnWNnwkWag9ElbYeR7Ir7WXdNVi70PHDTIVfZ05nM,1000
18
+ asyncly-0.3.0.dist-info/METADATA,sha256=6c85cr-1I10tCFoKpn8_yBYNLTbTEHtYkwWQKegScXY,4835
19
+ asyncly-0.3.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
20
+ asyncly-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any