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.
@@ -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