asyncly 0.3.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.3.0/PKG-INFO +145 -0
- asyncly-0.3.0/README.rst +104 -0
- asyncly-0.3.0/asyncly/__init__.py +12 -0
- asyncly-0.3.0/asyncly/client/__init__.py +0 -0
- asyncly-0.3.0/asyncly/client/base.py +50 -0
- asyncly-0.3.0/asyncly/client/handlers/__init__.py +0 -0
- asyncly-0.3.0/asyncly/client/handlers/base.py +39 -0
- asyncly-0.3.0/asyncly/client/handlers/exceptions.py +24 -0
- asyncly-0.3.0/asyncly/client/handlers/json.py +24 -0
- asyncly-0.3.0/asyncly/client/handlers/msgspec.py +15 -0
- asyncly-0.3.0/asyncly/client/handlers/pydantic.py +14 -0
- asyncly-0.3.0/asyncly/client/timeout.py +27 -0
- asyncly-0.3.0/asyncly/py.typed +0 -0
- asyncly-0.3.0/asyncly/srvmocker/__init__.py +10 -0
- asyncly-0.3.0/asyncly/srvmocker/handlers.py +21 -0
- asyncly-0.3.0/asyncly/srvmocker/models.py +37 -0
- asyncly-0.3.0/asyncly/srvmocker/responses.py +80 -0
- asyncly-0.3.0/asyncly/srvmocker/serialization.py +16 -0
- asyncly-0.3.0/asyncly/srvmocker/service.py +35 -0
- asyncly-0.3.0/pyproject.toml +136 -0
asyncly-0.3.0/PKG-INFO
ADDED
|
@@ -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
|
asyncly-0.3.0/README.rst
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Asyncly
|
|
2
|
+
=======
|
|
3
|
+
|
|
4
|
+
.. image:: https://img.shields.io/pypi/v/asyncly.svg
|
|
5
|
+
:target: https://pypi.python.org/pypi/asyncly/
|
|
6
|
+
:alt: Latest Version
|
|
7
|
+
|
|
8
|
+
.. image:: https://img.shields.io/pypi/wheel/base-http-client.svg
|
|
9
|
+
:target: https://pypi.python.org/pypi/base-http-client/
|
|
10
|
+
|
|
11
|
+
.. image:: https://img.shields.io/pypi/pyversions/base-http-client.svg
|
|
12
|
+
:target: https://pypi.python.org/pypi/base-http-client/
|
|
13
|
+
|
|
14
|
+
.. image:: https://img.shields.io/pypi/l/base-http-client.svg
|
|
15
|
+
:target: https://pypi.python.org/pypi/base-http-client/
|
|
16
|
+
|
|
17
|
+
Simple HTTP client and server for your integrations based on aiohttp_.
|
|
18
|
+
|
|
19
|
+
Installation
|
|
20
|
+
------------
|
|
21
|
+
|
|
22
|
+
Installation is possible in standard ways, such as PyPI or
|
|
23
|
+
installation from a git repository directly.
|
|
24
|
+
|
|
25
|
+
Installing from PyPI_:
|
|
26
|
+
|
|
27
|
+
.. code-block:: bash
|
|
28
|
+
|
|
29
|
+
pip install asyncly
|
|
30
|
+
|
|
31
|
+
Installing from github.com:
|
|
32
|
+
|
|
33
|
+
.. code-block:: bash
|
|
34
|
+
|
|
35
|
+
pip install git+https://github.com/andy-takker/asyncly
|
|
36
|
+
|
|
37
|
+
The package contains several extras and you can install additional dependencies
|
|
38
|
+
if you specify them in this way.
|
|
39
|
+
|
|
40
|
+
For example, with msgspec_:
|
|
41
|
+
|
|
42
|
+
.. code-block:: bash
|
|
43
|
+
|
|
44
|
+
pip install "asyncly[msgspec]"
|
|
45
|
+
|
|
46
|
+
Complete table of extras below:
|
|
47
|
+
|
|
48
|
+
+-------------------------------------+----------------------------------+
|
|
49
|
+
| example | description |
|
|
50
|
+
+========================================================================+
|
|
51
|
+
| ``pip install "asyncly[msgspec]"`` | For using msgspec_ structs |
|
|
52
|
+
+-------------------------------------+----------------------------------+
|
|
53
|
+
| ``pip install "asyncly[orjson]"`` | For fast parsing json by orjson_ |
|
|
54
|
+
+-------------------------------------+----------------------------------+
|
|
55
|
+
| ``pip install "asyncly[pydantic]"`` | For using pydantic_ models |
|
|
56
|
+
+-------------------------------------+----------------------------------+
|
|
57
|
+
|
|
58
|
+
Quick start guide
|
|
59
|
+
-----------------
|
|
60
|
+
|
|
61
|
+
BaseHttpClient
|
|
62
|
+
~~~~~~~~~~~~~~
|
|
63
|
+
|
|
64
|
+
Simple HTTP Client for `https://catfact.ninja`. See full example in `examples/catfact_client.py`_
|
|
65
|
+
|
|
66
|
+
.. code-block:: python
|
|
67
|
+
|
|
68
|
+
from base_http_client.client import (
|
|
69
|
+
DEFAULT_TIMEOUT,
|
|
70
|
+
BaseHttpClient,
|
|
71
|
+
ResponseHandlersType,
|
|
72
|
+
)
|
|
73
|
+
from base_http_client.handlers.pydantic import parse_model
|
|
74
|
+
from base_http_client.timeout import TimeoutType
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CatfactClient(BaseHttpClient):
|
|
78
|
+
RANDOM_CATFACT_HANDLERS: ResponseHandlersType = MappingProxyType(
|
|
79
|
+
{
|
|
80
|
+
HTTPStatus.OK: parse_model(CatfactSchema),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def fetch_random_cat_fact(
|
|
85
|
+
self,
|
|
86
|
+
timeout: TimeoutType = DEFAULT_TIMEOUT,
|
|
87
|
+
) -> CatfactSchema:
|
|
88
|
+
return await self._make_req(
|
|
89
|
+
method=hdrs.METH_GET,
|
|
90
|
+
url=self._url / "fact",
|
|
91
|
+
handlers=self.RANDOM_CATFACT_HANDLERS,
|
|
92
|
+
timeout=timeout,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
.. _PyPI: https://pypi.org/
|
|
99
|
+
.. _aiohttp: https://pypi.org/project/aiohttp/
|
|
100
|
+
.. _msgspec: https://github.com/jcrist/msgspec
|
|
101
|
+
.. _orjson: https://github.com/ijl/orjson
|
|
102
|
+
.. _pydantic: https://github.com/pydantic/pydantic
|
|
103
|
+
|
|
104
|
+
.. _examples/catfact_client.py: https://github.com/andy-takker/asyncly/blob/master/examples/catfact_client.py
|
|
@@ -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
|
|
@@ -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))
|
|
File without changes
|
|
@@ -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,136 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "asyncly"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Simple HTTP client and server for your integrations based on aiohttp"
|
|
5
|
+
authors = ["Sergey Natalenko <sergey.natalenko@mail.ru>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
homepage = "https://github.com/andy-takker/asyncly"
|
|
8
|
+
readme = "README.rst"
|
|
9
|
+
keywords = ["aiohttp", "http", "client"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Framework :: AsyncIO",
|
|
13
|
+
"Framework :: Pytest",
|
|
14
|
+
"Framework :: aiohttp",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Intended Audience :: Information Technology",
|
|
17
|
+
"Intended Audience :: System Administrators",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Internet",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
"Topic :: Software Development :: Libraries",
|
|
29
|
+
"Topic :: Software Development",
|
|
30
|
+
]
|
|
31
|
+
packages = [
|
|
32
|
+
{ include = "asyncly" },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.poetry.urls]
|
|
36
|
+
"Source" = "https://github.com/andy-takker/asyncly"
|
|
37
|
+
"Bug Tracker" = "https://github.com/andy-takker/v/issues"
|
|
38
|
+
|
|
39
|
+
[tool.poetry.dependencies]
|
|
40
|
+
python = "^3.10"
|
|
41
|
+
aiohttp = "^3.9.5"
|
|
42
|
+
msgspec = {version = "^0.18.6", optional = true}
|
|
43
|
+
orjson = {version = "^3.10.6", optional = true}
|
|
44
|
+
pydantic = {version = "^2.8.2", optional = true}
|
|
45
|
+
|
|
46
|
+
[tool.poetry.extras]
|
|
47
|
+
msgspec = ["msgspec"]
|
|
48
|
+
pydantic = ["pydantic"]
|
|
49
|
+
orjson = ["orjson"]
|
|
50
|
+
|
|
51
|
+
[tool.poetry.group.dev.dependencies]
|
|
52
|
+
pre-commit = ">=3.7.1,<5.0.0"
|
|
53
|
+
mypy = "^1.10.1"
|
|
54
|
+
ruff = ">=0.5.2,<0.8.0"
|
|
55
|
+
restructuredtext-lint = "^1.4.0"
|
|
56
|
+
pygments = "^2.18.0"
|
|
57
|
+
pytest = "^8.3.3"
|
|
58
|
+
pytest-asyncio = "^0.24.0"
|
|
59
|
+
|
|
60
|
+
[build-system]
|
|
61
|
+
requires = ["poetry-core"]
|
|
62
|
+
build-backend = "poetry.core.masonry.api"
|
|
63
|
+
|
|
64
|
+
[tool.pytest.ini_options]
|
|
65
|
+
asyncio_mode = "auto"
|
|
66
|
+
python_files = "test_*"
|
|
67
|
+
python_functions = "test_*"
|
|
68
|
+
python_classes = "TestSuite*"
|
|
69
|
+
addopts = "-p no:cacheprovider"
|
|
70
|
+
|
|
71
|
+
[tool.coverage.run]
|
|
72
|
+
branch = true
|
|
73
|
+
source = ["asyncly"]
|
|
74
|
+
command_line = "-m pytest"
|
|
75
|
+
|
|
76
|
+
[tool.coverage.report]
|
|
77
|
+
show_missing = true
|
|
78
|
+
|
|
79
|
+
[tool.coverage.xml]
|
|
80
|
+
output = "coverage.xml"
|
|
81
|
+
|
|
82
|
+
[tool.ruff]
|
|
83
|
+
line-length = 88
|
|
84
|
+
exclude = [
|
|
85
|
+
".git",
|
|
86
|
+
".mypy_cache",
|
|
87
|
+
".ruff_cache",
|
|
88
|
+
".venv",
|
|
89
|
+
]
|
|
90
|
+
indent-width = 4
|
|
91
|
+
target-version = "py310"
|
|
92
|
+
|
|
93
|
+
[tool.ruff.format]
|
|
94
|
+
quote-style = "double"
|
|
95
|
+
indent-style = "space"
|
|
96
|
+
|
|
97
|
+
[tool.ruff.lint]
|
|
98
|
+
select = [
|
|
99
|
+
"BLE",
|
|
100
|
+
"C90",
|
|
101
|
+
"E",
|
|
102
|
+
"F",
|
|
103
|
+
"G",
|
|
104
|
+
"I",
|
|
105
|
+
"ICN",
|
|
106
|
+
"ISC",
|
|
107
|
+
"PLE",
|
|
108
|
+
"Q",
|
|
109
|
+
"RUF006",
|
|
110
|
+
"RUF100",
|
|
111
|
+
"T10",
|
|
112
|
+
"T20",
|
|
113
|
+
"TID",
|
|
114
|
+
"UP",
|
|
115
|
+
"W",
|
|
116
|
+
]
|
|
117
|
+
ignore = ["ISC001"]
|
|
118
|
+
fixable = ["ALL"]
|
|
119
|
+
|
|
120
|
+
[tool.ruff.lint.isort]
|
|
121
|
+
known-first-party = ["asyncly", "tests"]
|
|
122
|
+
|
|
123
|
+
[tool.mypy]
|
|
124
|
+
plugins = ["pydantic.mypy"]
|
|
125
|
+
check_untyped_defs = true
|
|
126
|
+
disallow_incomplete_defs = true
|
|
127
|
+
disallow_untyped_defs = true
|
|
128
|
+
ignore_missing_imports = false
|
|
129
|
+
no_implicit_optional = true
|
|
130
|
+
|
|
131
|
+
[[tool.mypy.overrides]]
|
|
132
|
+
module = [
|
|
133
|
+
"msgspec.*",
|
|
134
|
+
"orjson.*",
|
|
135
|
+
]
|
|
136
|
+
ignore_missing_imports = true
|