flowion 0.2.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.
- flowion/__init__.py +56 -0
- flowion/_json.py +145 -0
- flowion/_operation.py +87 -0
- flowion/_wire.py +55 -0
- flowion/aiohttp_transport.py +96 -0
- flowion/client.py +330 -0
- flowion/clock.py +30 -0
- flowion/errors.py +158 -0
- flowion/generated/__init__.py +8 -0
- flowion/generated/api.py +3690 -0
- flowion/generated/contract.py +1085 -0
- flowion/generated/operations.py +6050 -0
- flowion/generated/types.py +23311 -0
- flowion/oauth.py +300 -0
- flowion/pagination.py +53 -0
- flowion/py.typed +0 -0
- flowion/rate_limit.py +45 -0
- flowion/retry.py +78 -0
- flowion/sse.py +159 -0
- flowion/transport.py +64 -0
- flowion-0.2.0.dist-info/METADATA +293 -0
- flowion-0.2.0.dist-info/RECORD +25 -0
- flowion-0.2.0.dist-info/WHEEL +4 -0
- flowion-0.2.0.dist-info/licenses/LICENSE-APACHE +202 -0
- flowion-0.2.0.dist-info/licenses/LICENSE-MIT +21 -0
flowion/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The Flowion API client for Python.
|
|
2
|
+
|
|
3
|
+
Async-first, standard library only. Bring your own HTTP library through the
|
|
4
|
+
`Transport` protocol, or use the `aiohttp` adapter (`pip install "flowion[aiohttp]"`,
|
|
5
|
+
`flowion.aiohttp_transport.AiohttpTransport`) over a session you already have.
|
|
6
|
+
The public-client OAuth helpers live in `flowion.oauth`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from flowion.client import Client, TokenProvider
|
|
10
|
+
from flowion.clock import Clock, SystemClock
|
|
11
|
+
from flowion.errors import (
|
|
12
|
+
AccessDenied,
|
|
13
|
+
ApiError,
|
|
14
|
+
DecodeError,
|
|
15
|
+
FlowionError,
|
|
16
|
+
InvalidClient,
|
|
17
|
+
InvalidGrant,
|
|
18
|
+
InvalidRequest,
|
|
19
|
+
OAuthError,
|
|
20
|
+
RateLimitedError,
|
|
21
|
+
StateMismatch,
|
|
22
|
+
)
|
|
23
|
+
from flowion.oauth import AuthorizationRequest, AuthorizationResult, OAuthClient, TokenSet
|
|
24
|
+
from flowion.rate_limit import RateLimit
|
|
25
|
+
from flowion.retry import RetryPolicy
|
|
26
|
+
from flowion.sse import EventStream
|
|
27
|
+
from flowion.transport import Request, Response, StreamResponse, Transport, TransportError
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AccessDenied",
|
|
31
|
+
"ApiError",
|
|
32
|
+
"AuthorizationRequest",
|
|
33
|
+
"AuthorizationResult",
|
|
34
|
+
"Client",
|
|
35
|
+
"Clock",
|
|
36
|
+
"DecodeError",
|
|
37
|
+
"EventStream",
|
|
38
|
+
"FlowionError",
|
|
39
|
+
"InvalidClient",
|
|
40
|
+
"InvalidGrant",
|
|
41
|
+
"InvalidRequest",
|
|
42
|
+
"OAuthClient",
|
|
43
|
+
"OAuthError",
|
|
44
|
+
"RateLimit",
|
|
45
|
+
"RateLimitedError",
|
|
46
|
+
"Request",
|
|
47
|
+
"Response",
|
|
48
|
+
"RetryPolicy",
|
|
49
|
+
"StateMismatch",
|
|
50
|
+
"StreamResponse",
|
|
51
|
+
"SystemClock",
|
|
52
|
+
"TokenProvider",
|
|
53
|
+
"TokenSet",
|
|
54
|
+
"Transport",
|
|
55
|
+
"TransportError",
|
|
56
|
+
]
|
flowion/_json.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The JSON value model and the strict readers generated code decodes with.
|
|
2
|
+
|
|
3
|
+
This module is one half of the seam between ``flowion.generated`` (emitted,
|
|
4
|
+
never hand-edited) and the runtime; its shape is fixed by
|
|
5
|
+
``specs/167-python-sdk/contracts/generated-runtime-interface.md``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import date, datetime
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"UNSET",
|
|
15
|
+
"DecodePathError",
|
|
16
|
+
"JsonValue",
|
|
17
|
+
"Unset",
|
|
18
|
+
"expect_bool",
|
|
19
|
+
"expect_date",
|
|
20
|
+
"expect_datetime",
|
|
21
|
+
"expect_int",
|
|
22
|
+
"expect_list",
|
|
23
|
+
"expect_number",
|
|
24
|
+
"expect_object",
|
|
25
|
+
"expect_str",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Unset:
|
|
32
|
+
"""The marker for "leave this field unchanged" in a patch (``None`` clears it)."""
|
|
33
|
+
|
|
34
|
+
__slots__ = ()
|
|
35
|
+
_instance: Unset | None = None
|
|
36
|
+
|
|
37
|
+
def __new__(cls) -> Unset:
|
|
38
|
+
if cls._instance is None:
|
|
39
|
+
cls._instance = super().__new__(cls)
|
|
40
|
+
return cls._instance
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
return "UNSET"
|
|
44
|
+
|
|
45
|
+
def __bool__(self) -> bool:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
def __copy__(self) -> Unset:
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def __deepcopy__(self, memo: object) -> Unset:
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
UNSET: Final[Unset] = Unset()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DecodePathError(ValueError):
|
|
59
|
+
"""A value did not have the shape the contract declares.
|
|
60
|
+
|
|
61
|
+
``path`` locates it inside the response body, for example ``data[3].state``
|
|
62
|
+
(empty for the body itself). The client turns this into the public
|
|
63
|
+
``DecodeError``.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
path: str
|
|
67
|
+
|
|
68
|
+
def __init__(self, path: str, message: str) -> None:
|
|
69
|
+
super().__init__(f"{path or '<body>'}: {message}")
|
|
70
|
+
self.path = path
|
|
71
|
+
self.message = message
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _describe(value: JsonValue) -> str:
|
|
75
|
+
if value is None:
|
|
76
|
+
return "null"
|
|
77
|
+
if isinstance(value, bool):
|
|
78
|
+
return "a boolean"
|
|
79
|
+
if isinstance(value, int):
|
|
80
|
+
return "an integer"
|
|
81
|
+
if isinstance(value, float):
|
|
82
|
+
return "a number"
|
|
83
|
+
if isinstance(value, str):
|
|
84
|
+
return "a string"
|
|
85
|
+
if isinstance(value, list):
|
|
86
|
+
return "an array"
|
|
87
|
+
return "an object"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def expect_object(value: JsonValue, path: str) -> dict[str, JsonValue]:
|
|
91
|
+
if not isinstance(value, dict):
|
|
92
|
+
raise DecodePathError(path, f"expected an object, got {_describe(value)}")
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def expect_list(value: JsonValue, path: str) -> list[JsonValue]:
|
|
97
|
+
if not isinstance(value, list):
|
|
98
|
+
raise DecodePathError(path, f"expected an array, got {_describe(value)}")
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def expect_str(value: JsonValue, path: str) -> str:
|
|
103
|
+
if not isinstance(value, str):
|
|
104
|
+
raise DecodePathError(path, f"expected a string, got {_describe(value)}")
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def expect_int(value: JsonValue, path: str) -> int:
|
|
109
|
+
"""An integer: ``True`` and ``2.0`` are both refused."""
|
|
110
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
111
|
+
raise DecodePathError(path, f"expected an integer, got {_describe(value)}")
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def expect_number(value: JsonValue, path: str) -> float:
|
|
116
|
+
"""An int or a float, never a bool."""
|
|
117
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
118
|
+
raise DecodePathError(path, f"expected a number, got {_describe(value)}")
|
|
119
|
+
return float(value)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def expect_bool(value: JsonValue, path: str) -> bool:
|
|
123
|
+
if not isinstance(value, bool):
|
|
124
|
+
raise DecodePathError(path, f"expected a boolean, got {_describe(value)}")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def expect_datetime(value: JsonValue, path: str) -> datetime:
|
|
129
|
+
"""An RFC 3339 timestamp. The offset is required and the result is aware."""
|
|
130
|
+
text = expect_str(value, path)
|
|
131
|
+
try:
|
|
132
|
+
stamp = datetime.fromisoformat(text)
|
|
133
|
+
except ValueError:
|
|
134
|
+
raise DecodePathError(path, f"expected an RFC 3339 timestamp, got {text!r}") from None
|
|
135
|
+
if stamp.tzinfo is None:
|
|
136
|
+
raise DecodePathError(path, f"expected a timestamp with an offset, got {text!r}")
|
|
137
|
+
return stamp
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def expect_date(value: JsonValue, path: str) -> date:
|
|
141
|
+
text = expect_str(value, path)
|
|
142
|
+
try:
|
|
143
|
+
return date.fromisoformat(text)
|
|
144
|
+
except ValueError:
|
|
145
|
+
raise DecodePathError(path, f"expected a date (YYYY-MM-DD), got {text!r}") from None
|
flowion/_operation.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""What generated operations describe, and what they call.
|
|
2
|
+
|
|
3
|
+
This module is the other half of the seam between ``flowion.generated`` and
|
|
4
|
+
the runtime; its shape is fixed by
|
|
5
|
+
``specs/167-python-sdk/contracts/generated-runtime-interface.md``. ``Caller`` is
|
|
6
|
+
implemented by the client (``flowion/client.py``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal, Protocol
|
|
14
|
+
|
|
15
|
+
from flowion._json import JsonValue
|
|
16
|
+
|
|
17
|
+
__all__ = ["Caller", "OperationSpec"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class OperationSpec:
|
|
22
|
+
"""One operation of the contract, as the runtime needs to send it."""
|
|
23
|
+
|
|
24
|
+
operation_id: str
|
|
25
|
+
"""snake_case, as in ``openapi.json``."""
|
|
26
|
+
method: str
|
|
27
|
+
"""``"GET"``, ``"POST"``, ..."""
|
|
28
|
+
path: str
|
|
29
|
+
"""The path template, for example ``/v1/charging-stations/{chargingStationId}``."""
|
|
30
|
+
response: Literal["json", "text", "stream", "empty"]
|
|
31
|
+
"""``empty`` is a ``204`` or a response with no body."""
|
|
32
|
+
success_statuses: tuple[int, ...]
|
|
33
|
+
"""``(200,)``, ``(200, 201)`` or ``(200, 204)``."""
|
|
34
|
+
decode: Callable[[JsonValue], object] | None
|
|
35
|
+
"""Decodes the response body; ``None`` unless ``response == "json"``."""
|
|
36
|
+
paginated_item: Callable[[JsonValue], object] | None
|
|
37
|
+
"""Decodes one item when the response is a cursor page, else ``None``."""
|
|
38
|
+
request_body: Literal["none", "json"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Caller(Protocol):
|
|
42
|
+
"""The transport-facing half that generated groups call."""
|
|
43
|
+
|
|
44
|
+
async def call(
|
|
45
|
+
self,
|
|
46
|
+
spec: OperationSpec,
|
|
47
|
+
*,
|
|
48
|
+
path: Mapping[str, str] = ...,
|
|
49
|
+
query: Mapping[str, JsonValue] = ...,
|
|
50
|
+
body: JsonValue = None,
|
|
51
|
+
headers: Mapping[str, str] = ...,
|
|
52
|
+
) -> object:
|
|
53
|
+
"""Send ``spec`` and return what ``spec.decode`` returned.
|
|
54
|
+
|
|
55
|
+
``str`` for ``text``, ``None`` for ``empty``. Query values that are
|
|
56
|
+
``None`` are omitted and lists are repeated parameters; path values are
|
|
57
|
+
percent-encoded here, not by the caller.
|
|
58
|
+
"""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def paginate(
|
|
62
|
+
self,
|
|
63
|
+
spec: OperationSpec,
|
|
64
|
+
*,
|
|
65
|
+
path: Mapping[str, str] = ...,
|
|
66
|
+
query: Mapping[str, JsonValue] = ...,
|
|
67
|
+
headers: Mapping[str, str] = ...,
|
|
68
|
+
) -> AsyncIterator[object]:
|
|
69
|
+
"""Yield each item of every page, decoded by ``spec.paginated_item``."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
def stream(
|
|
73
|
+
self,
|
|
74
|
+
spec: OperationSpec,
|
|
75
|
+
*,
|
|
76
|
+
path: Mapping[str, str] = ...,
|
|
77
|
+
query: Mapping[str, JsonValue] = ...,
|
|
78
|
+
body: JsonValue = None,
|
|
79
|
+
headers: Mapping[str, str] = ...,
|
|
80
|
+
) -> AsyncIterator[object]:
|
|
81
|
+
"""Yield the parsed JSON payload of each ``data:`` frame.
|
|
82
|
+
|
|
83
|
+
``body`` is the JSON request body of a streaming operation that takes
|
|
84
|
+
one (the assistant's ``send_*_message`` POSTs): the contract's
|
|
85
|
+
original ``stream`` signature had no body.
|
|
86
|
+
"""
|
|
87
|
+
...
|
flowion/_wire.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Small shared helpers for reading what came off the wire. Private to the runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from flowion._json import JsonValue
|
|
11
|
+
from flowion.errors import EXCERPT_LIMIT
|
|
12
|
+
|
|
13
|
+
__all__ = ["LOGGER", "excerpt_of", "parse_json", "path_of", "request_id_of"]
|
|
14
|
+
|
|
15
|
+
# The one logger of the SDK. DEBUG only: method, path, status and request id.
|
|
16
|
+
# Never a header, a body, a token or a query string.
|
|
17
|
+
LOGGER = logging.getLogger("flowion")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_json(body: bytes) -> JsonValue:
|
|
21
|
+
"""The body as JSON; `ValueError` (which `UnicodeDecodeError` is) when it is not."""
|
|
22
|
+
try:
|
|
23
|
+
value: JsonValue = json.loads(body.decode("utf-8"))
|
|
24
|
+
except RecursionError:
|
|
25
|
+
raise ValueError("JSON nested too deeply") from None
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def best_effort_json(body: bytes) -> JsonValue:
|
|
30
|
+
"""The body as JSON, or `None` when it is empty or not JSON (for error bodies)."""
|
|
31
|
+
if not body:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
return parse_json(body)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def excerpt_of(body: bytes) -> str:
|
|
40
|
+
"""The start of a body as text, bounded to 512 characters; undecodable bytes become U+FFFD."""
|
|
41
|
+
return body[: EXCERPT_LIMIT * 4].decode("utf-8", errors="replace")[:EXCERPT_LIMIT]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def path_of(url: str) -> str:
|
|
45
|
+
"""The path of a URL, for logging: never the query string."""
|
|
46
|
+
return urlsplit(url).path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def request_id_of(headers: Mapping[str, str], body: JsonValue) -> str | None:
|
|
50
|
+
"""The request's id, from the error body or the `x-request-id` header."""
|
|
51
|
+
if isinstance(body, dict):
|
|
52
|
+
in_body = body.get("requestId")
|
|
53
|
+
if isinstance(in_body, str):
|
|
54
|
+
return in_body
|
|
55
|
+
return headers.get("x-request-id")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""A `Transport` over an existing `aiohttp.ClientSession` (the optional `aiohttp` extra).
|
|
2
|
+
|
|
3
|
+
from flowion.aiohttp_transport import AiohttpTransport
|
|
4
|
+
client = Client(transport=AiohttpTransport(session)) # session: owned by the caller
|
|
5
|
+
|
|
6
|
+
The session belongs to the caller and may be shared with other components (Home
|
|
7
|
+
Assistant's is). This adapter therefore only ever calls `session.request(...)`
|
|
8
|
+
with the headers and timeout of that one request: it never touches
|
|
9
|
+
`session.headers`, never writes the cookie jar, never closes anything. It cannot
|
|
10
|
+
stop aiohttp sending a cookie the session already holds, and does not try; the
|
|
11
|
+
gateway is bearer-only and cookie-free, which is what makes sharing safe.
|
|
12
|
+
|
|
13
|
+
Needs aiohttp 3.9.0 or newer: `pip install "flowion[aiohttp]"`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import aiohttp
|
|
20
|
+
except ImportError as error: # pragma: no cover - exercised by blocking the import in a test
|
|
21
|
+
raise ImportError(
|
|
22
|
+
'flowion.aiohttp_transport needs the aiohttp package: pip install "flowion[aiohttp]"'
|
|
23
|
+
) from error
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Mapping
|
|
27
|
+
from contextlib import asynccontextmanager
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from flowion.transport import Request, Response, StreamResponse, TransportError
|
|
31
|
+
|
|
32
|
+
__all__ = ["AiohttpTransport"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _lowercase(headers: Mapping[str, str] | Any) -> dict[str, str]:
|
|
36
|
+
"""Lowercase names; a repeated header is joined with ", " as HTTP allows."""
|
|
37
|
+
merged: dict[str, str] = {}
|
|
38
|
+
for name, value in headers.items():
|
|
39
|
+
key = str(name).lower()
|
|
40
|
+
merged[key] = f"{merged[key]}, {value}" if key in merged else str(value)
|
|
41
|
+
return merged
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AiohttpTransport:
|
|
45
|
+
"""Sends the SDK's requests through the caller's `aiohttp.ClientSession`."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, session: aiohttp.ClientSession) -> None:
|
|
48
|
+
self._session = session
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
return "AiohttpTransport()"
|
|
52
|
+
|
|
53
|
+
def _options(self, request: Request, *, streaming: bool) -> dict[str, Any]:
|
|
54
|
+
options: dict[str, Any] = {
|
|
55
|
+
"headers": dict(request.headers),
|
|
56
|
+
"data": request.body,
|
|
57
|
+
"allow_redirects": False,
|
|
58
|
+
}
|
|
59
|
+
if request.timeout is not None:
|
|
60
|
+
options["timeout"] = aiohttp.ClientTimeout(total=request.timeout)
|
|
61
|
+
elif streaming:
|
|
62
|
+
# A live stream has no total deadline; the session's own default would cut it.
|
|
63
|
+
options["timeout"] = aiohttp.ClientTimeout(total=None)
|
|
64
|
+
return options
|
|
65
|
+
|
|
66
|
+
async def send(self, request: Request) -> Response:
|
|
67
|
+
try:
|
|
68
|
+
async with self._session.request(
|
|
69
|
+
request.method, request.url, **self._options(request, streaming=False)
|
|
70
|
+
) as response:
|
|
71
|
+
body = await response.read()
|
|
72
|
+
return Response(status=response.status, headers=_lowercase(response.headers), body=body)
|
|
73
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as error:
|
|
74
|
+
raise TransportError(f"{type(error).__name__}: {error}") from error
|
|
75
|
+
|
|
76
|
+
@asynccontextmanager
|
|
77
|
+
async def stream(self, request: Request) -> AsyncGenerator[StreamResponse, None]:
|
|
78
|
+
try:
|
|
79
|
+
response = await self._session.request(request.method, request.url, **self._options(request, streaming=True))
|
|
80
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as error:
|
|
81
|
+
raise TransportError(f"{type(error).__name__}: {error}") from error
|
|
82
|
+
try:
|
|
83
|
+
yield StreamResponse(status=response.status, headers=_lowercase(response.headers), chunks=_chunks(response))
|
|
84
|
+
finally:
|
|
85
|
+
if response.content.at_eof():
|
|
86
|
+
response.release()
|
|
87
|
+
else:
|
|
88
|
+
response.close()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _chunks(response: aiohttp.ClientResponse) -> AsyncIterator[bytes]:
|
|
92
|
+
try:
|
|
93
|
+
async for chunk in response.content.iter_any():
|
|
94
|
+
yield chunk
|
|
95
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as error:
|
|
96
|
+
raise TransportError(f"{type(error).__name__}: {error}") from error
|