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/_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)