pipsocket 0.1.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.
- pipsocket/__init__.py +68 -0
- pipsocket/_core.py +712 -0
- pipsocket/_generated/__init__.py +1 -0
- pipsocket/_generated/operations.py +35 -0
- pipsocket/_generated/tables.py +95 -0
- pipsocket/_generated/types.py +542 -0
- pipsocket/_http.py +235 -0
- pipsocket/_stream_core.py +916 -0
- pipsocket/_version.py +1 -0
- pipsocket/aio.py +702 -0
- pipsocket/client.py +680 -0
- pipsocket/errors.py +275 -0
- pipsocket/models.py +151 -0
- pipsocket/py.typed +0 -0
- pipsocket/stream.py +713 -0
- pipsocket/types.py +48 -0
- pipsocket-0.1.0.dist-info/METADATA +358 -0
- pipsocket-0.1.0.dist-info/RECORD +20 -0
- pipsocket-0.1.0.dist-info/WHEEL +4 -0
- pipsocket-0.1.0.dist-info/licenses/LICENSE +21 -0
pipsocket/_http.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""What the sync and async clients share: options, headers, and reading httpx's outcomes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import AsyncIterator, Callable, Iterator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._core import Answer, BodyLost, NoAnswer, Settings, Spec
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .errors import PipsocketError
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.pipsocket.com"
|
|
21
|
+
DEFAULT_STREAM_URL = "wss://wss.pipsocket.com"
|
|
22
|
+
DEFAULT_TIMEOUT = 40.0
|
|
23
|
+
DEFAULT_MAX_RETRIES = 2
|
|
24
|
+
DEFAULT_RETRY_DEADLINE = 60.0
|
|
25
|
+
KEY_ENV = "PIPSOCKET_KEY"
|
|
26
|
+
_KEY_CHARACTERS = re.compile(r"[\x21-\x7e]+")
|
|
27
|
+
|
|
28
|
+
USER_AGENT = (
|
|
29
|
+
f"pipsocket-python/{__version__} "
|
|
30
|
+
f"{platform.python_implementation()}/{platform.python_version()} ({platform.system()})"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# httpx raises these before anything is written: the connection never opened.
|
|
34
|
+
_NOT_WRITTEN = (
|
|
35
|
+
httpx.ConnectError,
|
|
36
|
+
httpx.ConnectTimeout,
|
|
37
|
+
httpx.PoolTimeout,
|
|
38
|
+
httpx.ProxyError,
|
|
39
|
+
httpx.UnsupportedProtocol,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Options:
|
|
45
|
+
api_key: str
|
|
46
|
+
base_url: str
|
|
47
|
+
stream_url: str
|
|
48
|
+
timeout: float
|
|
49
|
+
max_retries: int
|
|
50
|
+
retry_deadline: float
|
|
51
|
+
clock: Callable[[], float]
|
|
52
|
+
random: Callable[[], float]
|
|
53
|
+
|
|
54
|
+
def settings(self, max_retries: int | None) -> Settings:
|
|
55
|
+
retries = self.max_retries if max_retries is None else check_retries(max_retries)
|
|
56
|
+
return Settings(retries, self.retry_deadline, self.clock, self.random)
|
|
57
|
+
|
|
58
|
+
def attempt_timeout(self, timeout: float | None) -> float:
|
|
59
|
+
return self.timeout if timeout is None else check_seconds("timeout", timeout)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def options(
|
|
63
|
+
api_key: str | None,
|
|
64
|
+
base_url: str | None,
|
|
65
|
+
stream_url: str | None,
|
|
66
|
+
timeout: float,
|
|
67
|
+
max_retries: int,
|
|
68
|
+
retry_deadline: float,
|
|
69
|
+
clock: Callable[[], float] | None,
|
|
70
|
+
rand: Callable[[], float] | None,
|
|
71
|
+
) -> Options:
|
|
72
|
+
key = api_key if api_key is not None else os.environ.get(KEY_ENV)
|
|
73
|
+
if not key:
|
|
74
|
+
raise PipsocketError(
|
|
75
|
+
f"No API key: pass api_key=, or set the {KEY_ENV} environment variable."
|
|
76
|
+
)
|
|
77
|
+
if not _KEY_CHARACTERS.fullmatch(key):
|
|
78
|
+
# Not echoed: an HTTP library refusing the header would put it in its error message.
|
|
79
|
+
raise PipsocketError("The API key has a space or a character a header can't carry.")
|
|
80
|
+
return Options(
|
|
81
|
+
api_key=key,
|
|
82
|
+
base_url=check_url("base_url", base_url or DEFAULT_BASE_URL, ("http://", "https://")),
|
|
83
|
+
stream_url=(stream_url or DEFAULT_STREAM_URL).rstrip("/"),
|
|
84
|
+
timeout=check_seconds("timeout", timeout),
|
|
85
|
+
max_retries=check_retries(max_retries),
|
|
86
|
+
retry_deadline=check_seconds("retry_deadline", retry_deadline),
|
|
87
|
+
clock=clock or time.monotonic,
|
|
88
|
+
random=rand or random.random,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check_url(name: str, url: str, schemes: tuple[str, ...]) -> str:
|
|
93
|
+
if not url.startswith(schemes):
|
|
94
|
+
raise ValueError(f"{name} starts with {' or '.join(schemes)}, not {url!r}.")
|
|
95
|
+
return url.rstrip("/")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def check_retries(value: int) -> int:
|
|
99
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0: # pyright: ignore[reportUnnecessaryIsInstance]
|
|
100
|
+
raise ValueError(f"max_retries is a whole number, 0 or more, not {value!r}.")
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def check_seconds(name: str, value: float) -> float:
|
|
105
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not value > 0: # pyright: ignore[reportUnnecessaryIsInstance]
|
|
106
|
+
raise ValueError(f"{name} is a number of seconds above 0, not {value!r}.")
|
|
107
|
+
return float(value)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class RequestBody(httpx.SyncByteStream, httpx.AsyncByteStream):
|
|
111
|
+
"""A request's body that records how far sending got: whether the transport began
|
|
112
|
+
sending the request (its headers, reported through httpcore's `trace` extension, or
|
|
113
|
+
reading this body), and whether connecting failed. HTTP/2 sends a request without a body
|
|
114
|
+
as headers alone, so the body may never be read: the headers are what counts."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, content: bytes) -> None:
|
|
117
|
+
self.content = content
|
|
118
|
+
self.sending_began = False
|
|
119
|
+
self.connect_failed = False
|
|
120
|
+
|
|
121
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
122
|
+
self.sending_began = True
|
|
123
|
+
if self.content:
|
|
124
|
+
yield self.content
|
|
125
|
+
|
|
126
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
127
|
+
self.sending_began = True
|
|
128
|
+
if self.content:
|
|
129
|
+
yield self.content
|
|
130
|
+
|
|
131
|
+
def trace(self, event: str, info: dict[str, Any]) -> None:
|
|
132
|
+
if event.endswith((".send_request_headers.started", ".send_request_body.started")):
|
|
133
|
+
self.sending_began = True
|
|
134
|
+
elif event.endswith(_CONNECT_FAILED):
|
|
135
|
+
self.connect_failed = True
|
|
136
|
+
|
|
137
|
+
async def atrace(self, event: str, info: dict[str, Any]) -> None:
|
|
138
|
+
self.trace(event, info)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# httpcore's trace events for a connection that could not be opened.
|
|
142
|
+
_CONNECT_FAILED = (
|
|
143
|
+
"connection.connect_tcp.failed",
|
|
144
|
+
"connection.connect_unix_socket.failed",
|
|
145
|
+
"connection.start_tls.failed",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ResponseBody(httpx.SyncByteStream, httpx.AsyncByteStream):
|
|
150
|
+
"""A response's raw stream that records when it has been read to the end, so a failure
|
|
151
|
+
after that (closing it) can be told from a body cut short."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, stream: httpx.SyncByteStream | httpx.AsyncByteStream) -> None:
|
|
154
|
+
self.stream = stream
|
|
155
|
+
self.finished = False
|
|
156
|
+
|
|
157
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
158
|
+
assert isinstance(self.stream, httpx.SyncByteStream)
|
|
159
|
+
yield from self.stream
|
|
160
|
+
self.finished = True
|
|
161
|
+
|
|
162
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
163
|
+
assert isinstance(self.stream, httpx.AsyncByteStream)
|
|
164
|
+
async for chunk in self.stream:
|
|
165
|
+
yield chunk
|
|
166
|
+
self.finished = True
|
|
167
|
+
|
|
168
|
+
def close(self) -> None:
|
|
169
|
+
assert isinstance(self.stream, httpx.SyncByteStream)
|
|
170
|
+
self.stream.close()
|
|
171
|
+
|
|
172
|
+
async def aclose(self) -> None:
|
|
173
|
+
assert isinstance(self.stream, httpx.AsyncByteStream)
|
|
174
|
+
await self.stream.aclose()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def watch_response(response: httpx.Response) -> ResponseBody:
|
|
178
|
+
body = ResponseBody(response.stream)
|
|
179
|
+
response.stream = body
|
|
180
|
+
return body
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def check_wait(timeout: float) -> float:
|
|
184
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout < 0: # pyright: ignore[reportUnnecessaryIsInstance]
|
|
185
|
+
raise ValueError(f"timeout is a number of seconds, 0 or more, not {timeout!r}.")
|
|
186
|
+
return float(timeout)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_request(
|
|
190
|
+
client: httpx.Client | httpx.AsyncClient, spec: Spec, opts: Options, timeout: float
|
|
191
|
+
) -> tuple[httpx.Request, RequestBody]:
|
|
192
|
+
"""The same request for every attempt of a call: method, URL, body and key don't change."""
|
|
193
|
+
headers = {
|
|
194
|
+
"Authorization": f"Bearer {opts.api_key}",
|
|
195
|
+
"User-Agent": USER_AGENT,
|
|
196
|
+
"Accept": "application/json",
|
|
197
|
+
}
|
|
198
|
+
if spec.body is not None:
|
|
199
|
+
headers["Content-Type"] = "application/json"
|
|
200
|
+
if spec.idempotency_key is not None:
|
|
201
|
+
headers["Idempotency-Key"] = spec.idempotency_key
|
|
202
|
+
body = RequestBody(spec.body or b"")
|
|
203
|
+
trace = body.atrace if isinstance(client, httpx.AsyncClient) else body.trace
|
|
204
|
+
request = client.build_request(
|
|
205
|
+
spec.method,
|
|
206
|
+
opts.base_url + spec.path,
|
|
207
|
+
params=list(spec.query) or None,
|
|
208
|
+
content=spec.body,
|
|
209
|
+
headers=headers,
|
|
210
|
+
timeout=httpx.Timeout(timeout),
|
|
211
|
+
extensions={"trace": trace},
|
|
212
|
+
)
|
|
213
|
+
request.stream = body
|
|
214
|
+
return request, body
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def no_answer(error: httpx.TransportError, body: RequestBody) -> NoAnswer:
|
|
218
|
+
"""A transport error. It proves nothing was written only with positive evidence: a
|
|
219
|
+
connect-phase error, the transport reporting that connecting failed, and nothing of the
|
|
220
|
+
request sent (neither its headers nor its body). Anything else, a response hook's own
|
|
221
|
+
failure included, leaves the attempt maybe sent."""
|
|
222
|
+
never_sent = isinstance(error, _NOT_WRITTEN) and body.connect_failed and not body.sending_began
|
|
223
|
+
return NoAnswer(
|
|
224
|
+
error=error,
|
|
225
|
+
written=not never_sent,
|
|
226
|
+
timed_out=isinstance(error, httpx.TimeoutException),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def answer(response: httpx.Response, content: bytes) -> Answer:
|
|
231
|
+
return Answer(status=response.status_code, headers=response.headers, content=content)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def body_lost(response: httpx.Response, error: Exception, content: bytes) -> BodyLost:
|
|
235
|
+
return BodyLost(status=response.status_code, error=error, content=content)
|