nanoping-api 11.1.0.dev0__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.
- nanoping_api/__init__.py +18 -0
- nanoping_api/_client.py +154 -0
- nanoping_api/_generated.py +4182 -0
- nanoping_api/_runtime.py +685 -0
- nanoping_api/py.typed +0 -0
- nanoping_api-11.1.0.dev0.dist-info/METADATA +63 -0
- nanoping_api-11.1.0.dev0.dist-info/RECORD +8 -0
- nanoping_api-11.1.0.dev0.dist-info/WHEEL +4 -0
nanoping_api/_runtime.py
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import contextlib
|
|
5
|
+
import dataclasses
|
|
6
|
+
import datetime
|
|
7
|
+
import enum
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
import socket
|
|
12
|
+
import types
|
|
13
|
+
import typing
|
|
14
|
+
import urllib.parse
|
|
15
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Iterator, Mapping, Sequence
|
|
16
|
+
from typing import Any, Generic, TypeVar
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import websockets.asyncio.client
|
|
20
|
+
import websockets.exceptions
|
|
21
|
+
import websockets.sync.client
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
Req = TypeVar("Req")
|
|
25
|
+
Res = TypeVar("Res")
|
|
26
|
+
|
|
27
|
+
Json = dict[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Code(enum.IntEnum):
|
|
31
|
+
"""The status codes an API error can carry."""
|
|
32
|
+
|
|
33
|
+
OK = 0
|
|
34
|
+
CANCELLED = 1
|
|
35
|
+
UNKNOWN = 2
|
|
36
|
+
INVALID_ARGUMENT = 3
|
|
37
|
+
DEADLINE_EXCEEDED = 4
|
|
38
|
+
NOT_FOUND = 5
|
|
39
|
+
ALREADY_EXISTS = 6
|
|
40
|
+
PERMISSION_DENIED = 7
|
|
41
|
+
RESOURCE_EXHAUSTED = 8
|
|
42
|
+
FAILED_PRECONDITION = 9
|
|
43
|
+
ABORTED = 10
|
|
44
|
+
OUT_OF_RANGE = 11
|
|
45
|
+
UNIMPLEMENTED = 12
|
|
46
|
+
INTERNAL = 13
|
|
47
|
+
UNAVAILABLE = 14
|
|
48
|
+
DATA_LOSS = 15
|
|
49
|
+
UNAUTHENTICATED = 16
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ApiError(Exception):
|
|
53
|
+
"""A call that failed.
|
|
54
|
+
|
|
55
|
+
`code` says why, and `status` is the HTTP status of the response, or 0 when
|
|
56
|
+
the error arrived on a stream or the node could not be reached.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, code: int, message: str, status: int = 0) -> None:
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
try:
|
|
62
|
+
self.code: Code | int = Code(code)
|
|
63
|
+
except ValueError:
|
|
64
|
+
self.code = code
|
|
65
|
+
self.message = message
|
|
66
|
+
self.status = status
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
return f"ApiError(code={self.code!r}, message={self.message!r}, status={self.status})"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ProtoEnum(enum.StrEnum):
|
|
73
|
+
"""An enum of the API. A value this version does not know is kept as it is."""
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def _missing_(cls, value: object) -> ProtoEnum:
|
|
77
|
+
member = str.__new__(cls, str(value))
|
|
78
|
+
member._name_ = str(value)
|
|
79
|
+
member._value_ = str(value)
|
|
80
|
+
return member
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclasses.dataclass(frozen=True)
|
|
84
|
+
class Route:
|
|
85
|
+
"""One REST binding of a method.
|
|
86
|
+
|
|
87
|
+
`body` is "*" for the whole request, the name of one request field, or ""
|
|
88
|
+
for no body. Path parameters are named after the request fields they hold.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
method: str
|
|
92
|
+
path: str
|
|
93
|
+
body: str
|
|
94
|
+
path_params: tuple[tuple[str, str], ...] = dataclasses.field(init=False)
|
|
95
|
+
body_field: str = dataclasses.field(init=False)
|
|
96
|
+
|
|
97
|
+
def __post_init__(self) -> None:
|
|
98
|
+
params = tuple((param, json_name(param)) for param in re.findall(r"\{([^}]+)\}", self.path))
|
|
99
|
+
object.__setattr__(self, "path_params", params)
|
|
100
|
+
object.__setattr__(self, "body_field", self.body if self.body in ("*", "") else json_name(self.body))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def json_name(attribute: str) -> str:
|
|
104
|
+
"""The JSON name of a message attribute, such as nodeId for node_id."""
|
|
105
|
+
head, *rest = attribute.rstrip("_").split("_")
|
|
106
|
+
return head + "".join(part[:1].upper() + part[1:] for part in rest)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_field_cache: dict[type, tuple[tuple[str, str, Any, Any], ...]] = {}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _fields(cls: type) -> tuple[tuple[str, str, Any, Any], ...]:
|
|
113
|
+
if cls in _field_cache:
|
|
114
|
+
return _field_cache[cls]
|
|
115
|
+
hints = typing.get_type_hints(cls)
|
|
116
|
+
result = []
|
|
117
|
+
for field in dataclasses.fields(cls):
|
|
118
|
+
if field.default is not dataclasses.MISSING:
|
|
119
|
+
default = field.default
|
|
120
|
+
elif field.default_factory is not dataclasses.MISSING:
|
|
121
|
+
default = field.default_factory()
|
|
122
|
+
else:
|
|
123
|
+
default = dataclasses.MISSING
|
|
124
|
+
result.append((field.name, json_name(field.name), hints[field.name], default))
|
|
125
|
+
_field_cache[cls] = tuple(result)
|
|
126
|
+
return _field_cache[cls]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _format_timestamp(value: datetime.datetime) -> str:
|
|
130
|
+
return value.astimezone(datetime.UTC).isoformat().replace("+00:00", "Z")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _encode(value: Any) -> Any:
|
|
134
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
135
|
+
return to_json(value)
|
|
136
|
+
if isinstance(value, enum.Enum):
|
|
137
|
+
return value.value
|
|
138
|
+
if isinstance(value, bool | int | str):
|
|
139
|
+
return value
|
|
140
|
+
if isinstance(value, float):
|
|
141
|
+
if math.isnan(value):
|
|
142
|
+
return "NaN"
|
|
143
|
+
if math.isinf(value):
|
|
144
|
+
return "Infinity" if value > 0 else "-Infinity"
|
|
145
|
+
return value
|
|
146
|
+
if isinstance(value, bytes):
|
|
147
|
+
return base64.b64encode(value).decode()
|
|
148
|
+
if isinstance(value, datetime.datetime):
|
|
149
|
+
return _format_timestamp(value)
|
|
150
|
+
if isinstance(value, Mapping):
|
|
151
|
+
return {_encode_key(key): _encode(item) for key, item in value.items()}
|
|
152
|
+
if isinstance(value, Sequence):
|
|
153
|
+
return [_encode(item) for item in value]
|
|
154
|
+
raise TypeError(f"cannot send a {type(value).__name__} to the API")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _encode_key(key: Any) -> str:
|
|
158
|
+
if isinstance(key, bool):
|
|
159
|
+
return "true" if key else "false"
|
|
160
|
+
return str(key)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def to_json(message: Any) -> Json:
|
|
164
|
+
"""The proto JSON of a message, leaving out fields that hold their default."""
|
|
165
|
+
result: Json = {}
|
|
166
|
+
for attribute, name, _, default in _fields(type(message)):
|
|
167
|
+
value = getattr(message, attribute)
|
|
168
|
+
if value is None or (default is not None and value == default):
|
|
169
|
+
continue
|
|
170
|
+
result[name] = _encode(value)
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _unwrap_optional(hint: Any) -> Any:
|
|
175
|
+
if typing.get_origin(hint) in (typing.Union, types.UnionType):
|
|
176
|
+
return next(arg for arg in typing.get_args(hint) if arg is not type(None))
|
|
177
|
+
return hint
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _decode(hint: Any, value: Any) -> Any:
|
|
181
|
+
hint = _unwrap_optional(hint)
|
|
182
|
+
origin = typing.get_origin(hint)
|
|
183
|
+
if origin is list:
|
|
184
|
+
(item_hint,) = typing.get_args(hint)
|
|
185
|
+
return [_decode(item_hint, item) for item in value]
|
|
186
|
+
if origin is dict:
|
|
187
|
+
key_hint, item_hint = typing.get_args(hint)
|
|
188
|
+
return {_decode_key(key_hint, key): _decode(item_hint, item) for key, item in value.items()}
|
|
189
|
+
if dataclasses.is_dataclass(hint):
|
|
190
|
+
return from_json(typing.cast(type, hint), value)
|
|
191
|
+
if hint is bool:
|
|
192
|
+
return value is True or value == "true"
|
|
193
|
+
if hint is int:
|
|
194
|
+
return int(value)
|
|
195
|
+
if hint is float:
|
|
196
|
+
return float(value)
|
|
197
|
+
if hint is bytes:
|
|
198
|
+
return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_")
|
|
199
|
+
if hint is datetime.datetime:
|
|
200
|
+
return datetime.datetime.fromisoformat(value)
|
|
201
|
+
if isinstance(hint, type) and issubclass(hint, enum.Enum):
|
|
202
|
+
return hint(str(value))
|
|
203
|
+
return value
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _decode_key(hint: Any, key: str) -> Any:
|
|
207
|
+
if hint is int:
|
|
208
|
+
return int(key)
|
|
209
|
+
if hint is bool:
|
|
210
|
+
return key == "true"
|
|
211
|
+
return key
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def from_json(cls: type[T], data: Mapping[str, Any]) -> T:
|
|
215
|
+
"""Reads a message from its proto JSON. Unknown fields are ignored."""
|
|
216
|
+
values = {}
|
|
217
|
+
for attribute, name, hint, _ in _fields(cls):
|
|
218
|
+
value = data.get(name)
|
|
219
|
+
if value is not None:
|
|
220
|
+
values[attribute] = _decode(hint, value)
|
|
221
|
+
return cls(**values)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _is_set(value: Any) -> bool:
|
|
225
|
+
return value is not None and value != ""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# Maps and lists of messages cannot be sent in a query string.
|
|
229
|
+
def _needs_body(request: Json, path_fields: set[str]) -> bool:
|
|
230
|
+
return any(
|
|
231
|
+
field not in path_fields
|
|
232
|
+
and (isinstance(value, dict) or (isinstance(value, list) and any(isinstance(item, dict) for item in value)))
|
|
233
|
+
for field, value in request.items()
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Picks the first route whose path parameters are all set, preferring one that
|
|
238
|
+
# sends the whole request as the body when the request needs a body.
|
|
239
|
+
def _select_route(routes: Sequence[Route], request: Json) -> Route:
|
|
240
|
+
usable = [route for route in routes if all(_is_set(request.get(field)) for _, field in route.path_params)]
|
|
241
|
+
for route in usable:
|
|
242
|
+
if route.body == "*" and _needs_body(request, {field for _, field in route.path_params}):
|
|
243
|
+
return route
|
|
244
|
+
return usable[0] if usable else routes[0]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _append_query(query: list[tuple[str, str]], prefix: str, value: Any) -> None:
|
|
248
|
+
if value is None:
|
|
249
|
+
return
|
|
250
|
+
if isinstance(value, list):
|
|
251
|
+
for item in value:
|
|
252
|
+
_append_query(query, prefix, item)
|
|
253
|
+
elif isinstance(value, dict):
|
|
254
|
+
for key, item in value.items():
|
|
255
|
+
_append_query(query, f"{prefix}.{key}", item)
|
|
256
|
+
elif isinstance(value, bool):
|
|
257
|
+
query.append((prefix, "true" if value else "false"))
|
|
258
|
+
else:
|
|
259
|
+
query.append((prefix, str(value)))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _error_from_status(status: Any, fallback: str, http_status: int = 0) -> ApiError:
|
|
263
|
+
if not isinstance(status, dict):
|
|
264
|
+
return ApiError(Code.UNKNOWN, fallback, http_status)
|
|
265
|
+
return ApiError(status.get("code", Code.UNKNOWN), status.get("message") or fallback, http_status)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _error_from_body(body: bytes | bytearray, reason: str, http_status: int) -> ApiError:
|
|
269
|
+
try:
|
|
270
|
+
status = json.loads(body)
|
|
271
|
+
except ValueError:
|
|
272
|
+
status = None
|
|
273
|
+
return _error_from_status(status, reason, http_status)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _error_from_transport(error: Exception) -> ApiError:
|
|
277
|
+
if isinstance(error, httpx.TimeoutException):
|
|
278
|
+
return ApiError(Code.DEADLINE_EXCEEDED, f"timed out: {error}")
|
|
279
|
+
return ApiError(Code.UNAVAILABLE, f"could not reach the node: {error}")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _error_from_handshake(error: Exception) -> ApiError:
|
|
283
|
+
if isinstance(error, websockets.exceptions.InvalidStatus):
|
|
284
|
+
response = error.response
|
|
285
|
+
return _error_from_body(response.body or b"", response.reason_phrase, response.status_code)
|
|
286
|
+
return ApiError(Code.UNAVAILABLE, f"could not open stream: {error}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class _Requests:
|
|
290
|
+
"""Turns a call into the HTTP request of one of its routes."""
|
|
291
|
+
|
|
292
|
+
def __init__(self, base_url: str, headers: Mapping[str, str] | None) -> None:
|
|
293
|
+
self.base_url = base_url.rstrip("/")
|
|
294
|
+
self.headers = dict(headers or {})
|
|
295
|
+
|
|
296
|
+
def url(self, route: Route, request: Json, websocket: bool = False) -> str:
|
|
297
|
+
path_fields = {field for _, field in route.path_params}
|
|
298
|
+
path = route.path
|
|
299
|
+
for param, field in route.path_params:
|
|
300
|
+
path = path.replace(f"{{{param}}}", urllib.parse.quote(str(request.get(field, "")), safe=""))
|
|
301
|
+
|
|
302
|
+
query: list[tuple[str, str]] = []
|
|
303
|
+
if route.body != "*":
|
|
304
|
+
for field, value in request.items():
|
|
305
|
+
if field not in path_fields and field != route.body_field:
|
|
306
|
+
_append_query(query, field, value)
|
|
307
|
+
|
|
308
|
+
base = "ws" + self.base_url.removeprefix("http") if websocket else self.base_url
|
|
309
|
+
search = urllib.parse.urlencode(query)
|
|
310
|
+
return base + path + (f"?{search}" if search else "")
|
|
311
|
+
|
|
312
|
+
def body(self, route: Route, request: Json) -> bytes | None:
|
|
313
|
+
if route.body == "":
|
|
314
|
+
return None
|
|
315
|
+
if route.body != "*":
|
|
316
|
+
return json.dumps(request.get(route.body_field, {})).encode()
|
|
317
|
+
path_fields = {field for _, field in route.path_params}
|
|
318
|
+
return json.dumps({field: value for field, value in request.items() if field not in path_fields}).encode()
|
|
319
|
+
|
|
320
|
+
def build(
|
|
321
|
+
self,
|
|
322
|
+
client: httpx.Client | httpx.AsyncClient,
|
|
323
|
+
routes: Sequence[Route],
|
|
324
|
+
message: Any,
|
|
325
|
+
accept: str,
|
|
326
|
+
stream: bool,
|
|
327
|
+
) -> httpx.Request:
|
|
328
|
+
request = to_json(message)
|
|
329
|
+
route = _select_route(routes, request)
|
|
330
|
+
body = self.body(route, request)
|
|
331
|
+
headers = {"Accept": accept}
|
|
332
|
+
if body is not None:
|
|
333
|
+
headers["Content-Type"] = "application/json"
|
|
334
|
+
headers.update(self.headers)
|
|
335
|
+
# A quiet stream is not a stalled one.
|
|
336
|
+
timeout = client.timeout
|
|
337
|
+
if stream:
|
|
338
|
+
timeout = httpx.Timeout(connect=timeout.connect, read=None, write=timeout.write, pool=timeout.pool)
|
|
339
|
+
return client.build_request(
|
|
340
|
+
route.method, self.url(route, request), headers=headers, content=body, timeout=timeout
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# Parses server-sent events, yielding the data of each message and raising the
|
|
345
|
+
# status of an error event.
|
|
346
|
+
class _Events:
|
|
347
|
+
def __init__(self) -> None:
|
|
348
|
+
self.name = ""
|
|
349
|
+
self.data: list[str] = []
|
|
350
|
+
|
|
351
|
+
def line(self, line: str) -> Json | None:
|
|
352
|
+
if line == "":
|
|
353
|
+
name, data = self.name, "\n".join(self.data)
|
|
354
|
+
self.name, self.data = "", []
|
|
355
|
+
if not data:
|
|
356
|
+
return None
|
|
357
|
+
message = json.loads(data)
|
|
358
|
+
if name == "error":
|
|
359
|
+
raise _error_from_status(message, "stream failed")
|
|
360
|
+
return typing.cast(Json, message)
|
|
361
|
+
if line.startswith(":"):
|
|
362
|
+
return None
|
|
363
|
+
field, _, value = line.partition(":")
|
|
364
|
+
value = value.removeprefix(" ")
|
|
365
|
+
if field == "event":
|
|
366
|
+
self.name = value
|
|
367
|
+
elif field == "data":
|
|
368
|
+
self.data.append(value)
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _shutdown(response: httpx.Response) -> None:
|
|
373
|
+
stream = response.extensions.get("network_stream")
|
|
374
|
+
sock = stream.get_extra_info("socket") if stream is not None else None
|
|
375
|
+
if isinstance(sock, socket.socket):
|
|
376
|
+
with contextlib.suppress(OSError):
|
|
377
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class ServerStream(Generic[T]):
|
|
381
|
+
"""A stream of messages sent as server-sent events.
|
|
382
|
+
|
|
383
|
+
It is ready when the call returns, so no later message is missed. Read the
|
|
384
|
+
messages with `for`: the loop ends when the stream ends and raises an
|
|
385
|
+
ApiError when it fails. `close` stops it, also from another thread.
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
def __init__(self, response: httpx.Response, message_type: type[T]) -> None:
|
|
389
|
+
self._response = response
|
|
390
|
+
self._message_type = message_type
|
|
391
|
+
self._closed = False
|
|
392
|
+
self._ended = False
|
|
393
|
+
|
|
394
|
+
def __iter__(self) -> Iterator[T]:
|
|
395
|
+
events = _Events()
|
|
396
|
+
try:
|
|
397
|
+
for line in self._response.iter_lines():
|
|
398
|
+
message = events.line(line)
|
|
399
|
+
if message is not None:
|
|
400
|
+
yield from_json(self._message_type, message)
|
|
401
|
+
self._ended = True
|
|
402
|
+
except httpx.HTTPError as error:
|
|
403
|
+
if not self._closed:
|
|
404
|
+
raise ApiError(Code.UNAVAILABLE, f"stream failed: {error}") from error
|
|
405
|
+
finally:
|
|
406
|
+
self.close()
|
|
407
|
+
|
|
408
|
+
def close(self) -> None:
|
|
409
|
+
if not self._closed:
|
|
410
|
+
self._closed = True
|
|
411
|
+
# Wakes a read blocked in another thread.
|
|
412
|
+
if not self._ended:
|
|
413
|
+
_shutdown(self._response)
|
|
414
|
+
self._response.close()
|
|
415
|
+
|
|
416
|
+
def __enter__(self) -> ServerStream[T]:
|
|
417
|
+
return self
|
|
418
|
+
|
|
419
|
+
def __exit__(self, *_: object) -> None:
|
|
420
|
+
self.close()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class AsyncServerStream(Generic[T]):
|
|
424
|
+
"""A stream of messages sent as server-sent events.
|
|
425
|
+
|
|
426
|
+
`await` it, or enter it with `async with`, to open it: it is then ready, so
|
|
427
|
+
no later message is missed. Read the messages with `async for`, which opens
|
|
428
|
+
it first if needed: the loop ends when the stream ends and raises an
|
|
429
|
+
ApiError when it fails.
|
|
430
|
+
"""
|
|
431
|
+
|
|
432
|
+
def __init__(self, open: Callable[[], Awaitable[httpx.Response]], message_type: type[T]) -> None:
|
|
433
|
+
self._open = open
|
|
434
|
+
self._message_type = message_type
|
|
435
|
+
self._response: httpx.Response | None = None
|
|
436
|
+
|
|
437
|
+
async def _opened(self) -> httpx.Response:
|
|
438
|
+
if self._response is None:
|
|
439
|
+
self._response = await self._open()
|
|
440
|
+
return self._response
|
|
441
|
+
|
|
442
|
+
async def _ready(self) -> AsyncServerStream[T]:
|
|
443
|
+
await self._opened()
|
|
444
|
+
return self
|
|
445
|
+
|
|
446
|
+
def __await__(self) -> Generator[Any, None, AsyncServerStream[T]]:
|
|
447
|
+
return self._ready().__await__()
|
|
448
|
+
|
|
449
|
+
async def __aenter__(self) -> AsyncServerStream[T]:
|
|
450
|
+
return await self._ready()
|
|
451
|
+
|
|
452
|
+
async def __aexit__(self, *_: object) -> None:
|
|
453
|
+
await self.aclose()
|
|
454
|
+
|
|
455
|
+
async def __aiter__(self) -> AsyncIterator[T]:
|
|
456
|
+
response = await self._opened()
|
|
457
|
+
events = _Events()
|
|
458
|
+
try:
|
|
459
|
+
async for line in response.aiter_lines():
|
|
460
|
+
message = events.line(line)
|
|
461
|
+
if message is not None:
|
|
462
|
+
yield from_json(self._message_type, message)
|
|
463
|
+
except httpx.HTTPError as error:
|
|
464
|
+
if not response.is_closed:
|
|
465
|
+
raise ApiError(Code.UNAVAILABLE, f"stream failed: {error}") from error
|
|
466
|
+
finally:
|
|
467
|
+
await self.aclose()
|
|
468
|
+
|
|
469
|
+
async def aclose(self) -> None:
|
|
470
|
+
if self._response is not None:
|
|
471
|
+
await self._response.aclose()
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _received(raw: str | bytes, message_type: type[T]) -> T | None:
|
|
475
|
+
message = json.loads(raw)
|
|
476
|
+
if message.get("error") is not None:
|
|
477
|
+
raise _error_from_status(message["error"], "stream failed")
|
|
478
|
+
if message.get("result") is not None:
|
|
479
|
+
return from_json(message_type, message["result"])
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class BidiStream(Generic[Req, Res]):
|
|
484
|
+
"""A stream in both directions over a WebSocket.
|
|
485
|
+
|
|
486
|
+
Send requests with `send` and read responses with `for`. The loop ends when
|
|
487
|
+
the stream closes and raises an ApiError when the stream fails. `close`
|
|
488
|
+
stops it, also from another thread.
|
|
489
|
+
"""
|
|
490
|
+
|
|
491
|
+
def __init__(self, connection: websockets.sync.client.ClientConnection, message_type: type[Res]) -> None:
|
|
492
|
+
self._connection = connection
|
|
493
|
+
self._message_type = message_type
|
|
494
|
+
self._closed = False
|
|
495
|
+
|
|
496
|
+
def send(self, request: Req) -> None:
|
|
497
|
+
try:
|
|
498
|
+
self._connection.send(json.dumps(to_json(request)))
|
|
499
|
+
except websockets.exceptions.ConnectionClosed as error:
|
|
500
|
+
raise ApiError(Code.UNAVAILABLE, f"stream closed: {error}") from error
|
|
501
|
+
|
|
502
|
+
def __iter__(self) -> Iterator[Res]:
|
|
503
|
+
try:
|
|
504
|
+
for raw in self._connection:
|
|
505
|
+
message = _received(raw, self._message_type)
|
|
506
|
+
if message is not None:
|
|
507
|
+
yield message
|
|
508
|
+
except websockets.exceptions.ConnectionClosedError as error:
|
|
509
|
+
if not self._closed:
|
|
510
|
+
raise ApiError(Code.UNAVAILABLE, f"stream failed: {error}") from error
|
|
511
|
+
finally:
|
|
512
|
+
self.close()
|
|
513
|
+
|
|
514
|
+
def close(self) -> None:
|
|
515
|
+
self._closed = True
|
|
516
|
+
self._connection.close()
|
|
517
|
+
|
|
518
|
+
def __enter__(self) -> BidiStream[Req, Res]:
|
|
519
|
+
return self
|
|
520
|
+
|
|
521
|
+
def __exit__(self, *_: object) -> None:
|
|
522
|
+
self.close()
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class AsyncBidiStream(Generic[Req, Res]):
|
|
526
|
+
"""A stream in both directions over a WebSocket.
|
|
527
|
+
|
|
528
|
+
`await` it, or enter it with `async with`, to open it. Send requests with
|
|
529
|
+
`send` and read responses with `async for`, which both open it first if
|
|
530
|
+
needed. The loop ends when the stream closes and raises an ApiError when
|
|
531
|
+
the stream fails.
|
|
532
|
+
"""
|
|
533
|
+
|
|
534
|
+
def __init__(
|
|
535
|
+
self,
|
|
536
|
+
open: Callable[[], Awaitable[websockets.asyncio.client.ClientConnection]],
|
|
537
|
+
message_type: type[Res],
|
|
538
|
+
) -> None:
|
|
539
|
+
self._open = open
|
|
540
|
+
self._message_type = message_type
|
|
541
|
+
self._connection: websockets.asyncio.client.ClientConnection | None = None
|
|
542
|
+
self._closed = False
|
|
543
|
+
|
|
544
|
+
async def _opened(self) -> websockets.asyncio.client.ClientConnection:
|
|
545
|
+
if self._connection is None:
|
|
546
|
+
self._connection = await self._open()
|
|
547
|
+
return self._connection
|
|
548
|
+
|
|
549
|
+
async def _ready(self) -> AsyncBidiStream[Req, Res]:
|
|
550
|
+
await self._opened()
|
|
551
|
+
return self
|
|
552
|
+
|
|
553
|
+
def __await__(self) -> Generator[Any, None, AsyncBidiStream[Req, Res]]:
|
|
554
|
+
return self._ready().__await__()
|
|
555
|
+
|
|
556
|
+
async def __aenter__(self) -> AsyncBidiStream[Req, Res]:
|
|
557
|
+
return await self._ready()
|
|
558
|
+
|
|
559
|
+
async def __aexit__(self, *_: object) -> None:
|
|
560
|
+
await self.aclose()
|
|
561
|
+
|
|
562
|
+
async def send(self, request: Req) -> None:
|
|
563
|
+
connection = await self._opened()
|
|
564
|
+
try:
|
|
565
|
+
await connection.send(json.dumps(to_json(request)))
|
|
566
|
+
except websockets.exceptions.ConnectionClosed as error:
|
|
567
|
+
raise ApiError(Code.UNAVAILABLE, f"stream closed: {error}") from error
|
|
568
|
+
|
|
569
|
+
async def __aiter__(self) -> AsyncIterator[Res]:
|
|
570
|
+
connection = await self._opened()
|
|
571
|
+
try:
|
|
572
|
+
async for raw in connection:
|
|
573
|
+
message = _received(raw, self._message_type)
|
|
574
|
+
if message is not None:
|
|
575
|
+
yield message
|
|
576
|
+
except websockets.exceptions.ConnectionClosedError as error:
|
|
577
|
+
if not self._closed:
|
|
578
|
+
raise ApiError(Code.UNAVAILABLE, f"stream failed: {error}") from error
|
|
579
|
+
finally:
|
|
580
|
+
await self.aclose()
|
|
581
|
+
|
|
582
|
+
async def aclose(self) -> None:
|
|
583
|
+
self._closed = True
|
|
584
|
+
if self._connection is not None:
|
|
585
|
+
await self._connection.close()
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
class Transport:
|
|
589
|
+
"""Sends the calls of the blocking client."""
|
|
590
|
+
|
|
591
|
+
def __init__(
|
|
592
|
+
self,
|
|
593
|
+
base_url: str,
|
|
594
|
+
headers: Mapping[str, str] | None,
|
|
595
|
+
timeout: float | None,
|
|
596
|
+
http_client: httpx.Client | None,
|
|
597
|
+
) -> None:
|
|
598
|
+
self._requests = _Requests(base_url, headers)
|
|
599
|
+
self._owns_client = http_client is None
|
|
600
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
601
|
+
|
|
602
|
+
def _send(self, routes: Sequence[Route], message: Any, accept: str, stream: bool = False) -> httpx.Response:
|
|
603
|
+
request = self._requests.build(self._client, routes, message, accept, stream)
|
|
604
|
+
try:
|
|
605
|
+
response = self._client.send(request, stream=stream)
|
|
606
|
+
except httpx.TransportError as error:
|
|
607
|
+
raise _error_from_transport(error) from error
|
|
608
|
+
if response.is_error:
|
|
609
|
+
body = response.read()
|
|
610
|
+
response.close()
|
|
611
|
+
raise _error_from_body(body, response.reason_phrase, response.status_code)
|
|
612
|
+
return response
|
|
613
|
+
|
|
614
|
+
def unary(self, routes: Sequence[Route], message: Any, response_type: type[T]) -> T:
|
|
615
|
+
return from_json(response_type, self._send(routes, message, "application/json").json())
|
|
616
|
+
|
|
617
|
+
def text(self, routes: Sequence[Route], message: Any) -> str:
|
|
618
|
+
return self._send(routes, message, "text/plain").text
|
|
619
|
+
|
|
620
|
+
def server_stream(self, routes: Sequence[Route], message: Any, response_type: type[T]) -> ServerStream[T]:
|
|
621
|
+
return ServerStream(self._send(routes, message, "text/event-stream", stream=True), response_type)
|
|
622
|
+
|
|
623
|
+
def bidi_stream(self, route: Route, response_type: type[Res]) -> BidiStream[Any, Res]:
|
|
624
|
+
url = self._requests.url(route, {}, websocket=True)
|
|
625
|
+
try:
|
|
626
|
+
connection = websockets.sync.client.connect(url, additional_headers=self._requests.headers)
|
|
627
|
+
except (OSError, TimeoutError, websockets.exceptions.InvalidHandshake) as error:
|
|
628
|
+
raise _error_from_handshake(error) from error
|
|
629
|
+
return BidiStream(connection, response_type)
|
|
630
|
+
|
|
631
|
+
def close(self) -> None:
|
|
632
|
+
if self._owns_client:
|
|
633
|
+
self._client.close()
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class AsyncTransport:
|
|
637
|
+
"""Sends the calls of the asyncio client."""
|
|
638
|
+
|
|
639
|
+
def __init__(
|
|
640
|
+
self,
|
|
641
|
+
base_url: str,
|
|
642
|
+
headers: Mapping[str, str] | None,
|
|
643
|
+
timeout: float | None,
|
|
644
|
+
http_client: httpx.AsyncClient | None,
|
|
645
|
+
) -> None:
|
|
646
|
+
self._requests = _Requests(base_url, headers)
|
|
647
|
+
self._owns_client = http_client is None
|
|
648
|
+
self._client = http_client or httpx.AsyncClient(timeout=timeout)
|
|
649
|
+
|
|
650
|
+
async def _send(self, routes: Sequence[Route], message: Any, accept: str, stream: bool = False) -> httpx.Response:
|
|
651
|
+
request = self._requests.build(self._client, routes, message, accept, stream)
|
|
652
|
+
try:
|
|
653
|
+
response = await self._client.send(request, stream=stream)
|
|
654
|
+
except httpx.TransportError as error:
|
|
655
|
+
raise _error_from_transport(error) from error
|
|
656
|
+
if response.is_error:
|
|
657
|
+
body = await response.aread()
|
|
658
|
+
await response.aclose()
|
|
659
|
+
raise _error_from_body(body, response.reason_phrase, response.status_code)
|
|
660
|
+
return response
|
|
661
|
+
|
|
662
|
+
async def unary(self, routes: Sequence[Route], message: Any, response_type: type[T]) -> T:
|
|
663
|
+
response = await self._send(routes, message, "application/json")
|
|
664
|
+
return from_json(response_type, response.json())
|
|
665
|
+
|
|
666
|
+
async def text(self, routes: Sequence[Route], message: Any) -> str:
|
|
667
|
+
return (await self._send(routes, message, "text/plain")).text
|
|
668
|
+
|
|
669
|
+
def server_stream(self, routes: Sequence[Route], message: Any, response_type: type[T]) -> AsyncServerStream[T]:
|
|
670
|
+
return AsyncServerStream(lambda: self._send(routes, message, "text/event-stream", stream=True), response_type)
|
|
671
|
+
|
|
672
|
+
def bidi_stream(self, route: Route, response_type: type[Res]) -> AsyncBidiStream[Any, Res]:
|
|
673
|
+
url = self._requests.url(route, {}, websocket=True)
|
|
674
|
+
|
|
675
|
+
async def open() -> websockets.asyncio.client.ClientConnection:
|
|
676
|
+
try:
|
|
677
|
+
return await websockets.asyncio.client.connect(url, additional_headers=self._requests.headers)
|
|
678
|
+
except (OSError, TimeoutError, websockets.exceptions.InvalidHandshake) as error:
|
|
679
|
+
raise _error_from_handshake(error) from error
|
|
680
|
+
|
|
681
|
+
return AsyncBidiStream(open, response_type)
|
|
682
|
+
|
|
683
|
+
async def aclose(self) -> None:
|
|
684
|
+
if self._owns_client:
|
|
685
|
+
await self._client.aclose()
|
nanoping_api/py.typed
ADDED
|
File without changes
|