plimsoll-client 0.19.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.
@@ -0,0 +1,116 @@
1
+ """A Python client for plimsolld, the plimsoll sandbox daemon.
2
+
3
+ from plimsoll_client import Client
4
+
5
+ c = Client("http://127.0.0.1:8080", token="...")
6
+ r = c.run_javascript("console.log(6*7)", minimum_isolation="container")
7
+ print(r.stdout_text, r.isolation, r.record.sha256)
8
+
9
+ Standard library only. Every answered run's record is recomputed and checked, the
10
+ isolation floor is sent and the answer's tier checked against it, a session's
11
+ chain of records is followed call by call, and refusals come back as typed errors
12
+ that say whether anything ran. See README.md beside this package.
13
+ """
14
+
15
+ from ._record import VERSION as RECORD_VERSION
16
+ from ._transport import MAX_RESPONSE_BYTES
17
+ from ._version import __version__
18
+ from .aio import AsyncClient, AsyncSession
19
+ from .client import PROTOCOL, Client, Session
20
+ from .errors import (
21
+ AtCapacityError,
22
+ ChainError,
23
+ DataLossError,
24
+ DisabledError,
25
+ InsecureHTTPError,
26
+ InsufficientIsolationError,
27
+ InvalidBaseURLError,
28
+ InvalidOptionError,
29
+ InvalidRequestError,
30
+ IsolationEvidenceMismatchError,
31
+ MalformedResponseError,
32
+ NoRecordError,
33
+ PlimsollError,
34
+ ProtocolMismatchError,
35
+ RecordMismatchError,
36
+ RecordVersionError,
37
+ RequestTimeoutError,
38
+ ResponseTooLargeError,
39
+ ResultKindMismatchError,
40
+ SessionEndedError,
41
+ SoftwareEvidenceMismatchError,
42
+ SoftwareMismatchError,
43
+ TransportError,
44
+ UnsupportedError,
45
+ )
46
+ from .types import (
47
+ CellResult,
48
+ AdviceFinding,
49
+ Artifact,
50
+ Info,
51
+ JavaScriptResult,
52
+ ModuleResult,
53
+ ModuleRun,
54
+ PayloadEnvironment,
55
+ ProjectResult,
56
+ Resources,
57
+ RunEvidence,
58
+ RunRecord,
59
+ SessionEnded,
60
+ SessionSummary,
61
+ SoftwareRule,
62
+ StepResult,
63
+ meets,
64
+ )
65
+
66
+ __all__ = [
67
+ "CellResult",
68
+ "__version__",
69
+ "PROTOCOL",
70
+ "RECORD_VERSION",
71
+ "MAX_RESPONSE_BYTES",
72
+ "Client",
73
+ "Session",
74
+ "AsyncClient",
75
+ "AsyncSession",
76
+ "SoftwareRule",
77
+ "meets",
78
+ "Info",
79
+ "PayloadEnvironment",
80
+ "Resources",
81
+ "RunEvidence",
82
+ "RunRecord",
83
+ "AdviceFinding",
84
+ "JavaScriptResult",
85
+ "ProjectResult",
86
+ "StepResult",
87
+ "Artifact",
88
+ "ModuleResult",
89
+ "ModuleRun",
90
+ "SessionEnded",
91
+ "SessionSummary",
92
+ "PlimsollError",
93
+ "InvalidBaseURLError",
94
+ "InvalidOptionError",
95
+ "InsecureHTTPError",
96
+ "InvalidRequestError",
97
+ "InsufficientIsolationError",
98
+ "SoftwareMismatchError",
99
+ "DisabledError",
100
+ "UnsupportedError",
101
+ "ProtocolMismatchError",
102
+ "AtCapacityError",
103
+ "SessionEndedError",
104
+ "TransportError",
105
+ "RequestTimeoutError",
106
+ "ResponseTooLargeError",
107
+ "MalformedResponseError",
108
+ "DataLossError",
109
+ "RecordMismatchError",
110
+ "NoRecordError",
111
+ "RecordVersionError",
112
+ "ChainError",
113
+ "IsolationEvidenceMismatchError",
114
+ "SoftwareEvidenceMismatchError",
115
+ "ResultKindMismatchError",
116
+ ]
@@ -0,0 +1,300 @@
1
+ """Run-record digests and checks, ported from the Go package ``record``.
2
+
3
+ Every digest is lowercase hex SHA-256 over a length-prefixed encoding, never over
4
+ protobuf or JSON bytes (docs/run-records.md). The digests here are computed from
5
+ the JSON forms of the messages: the request exactly as this client sends it, the
6
+ response exactly as it was parsed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import struct
13
+ from typing import Iterable, Optional
14
+
15
+ from ._wire import INT32, INT64, OUTCOMES, UINT32, UINT64, Msg
16
+ from .errors import IsolationEvidenceMismatchError, NoRecordError, RecordMismatchError, RecordVersionError
17
+ from .types import RunRecord, SoftwareRule, meets
18
+
19
+ VERSION = 2
20
+ """The record encoding version this client computes and checks (record.Version)."""
21
+
22
+ UNANSWERED_VERSION = 3
23
+ """The record version of a session call that may have run but ended in an error
24
+ (record.UnansweredVersion): version 2's fields, then ``unanswered``."""
25
+
26
+ _UNANSWERED_CODES = frozenset((
27
+ "canceled", "unknown", "invalid_argument", "deadline_exceeded", "not_found", "already_exists",
28
+ "permission_denied", "resource_exhausted", "failed_precondition", "aborted", "out_of_range",
29
+ "unimplemented", "internal", "unavailable", "data_loss", "unauthenticated",
30
+ ))
31
+
32
+
33
+ class _Encoder:
34
+ def __init__(self, domain: str) -> None:
35
+ self._h = hashlib.sha256()
36
+ self._value(domain.encode("utf-8"))
37
+
38
+ def _value(self, b: bytes) -> None:
39
+ self._h.update(struct.pack(">Q", len(b)))
40
+ self._h.update(b)
41
+
42
+ def bytes(self, name: str, v: bytes) -> None:
43
+ self._value(name.encode("utf-8"))
44
+ self._value(v)
45
+
46
+ def str(self, name: str, v: str) -> None:
47
+ self.bytes(name, v.encode("utf-8"))
48
+
49
+ def int(self, name: str, v: int) -> None:
50
+ self.str(name, "%d" % v)
51
+
52
+ def bool(self, name: str, v: bool) -> None:
53
+ self.str(name, "true" if v else "false")
54
+
55
+ def floats(self, name: str, vs: Iterable[float]) -> None:
56
+ self.bytes(name, b"".join(struct.pack(">d", v) for v in vs))
57
+
58
+ def sum(self) -> str:
59
+ return self._h.hexdigest()
60
+
61
+
62
+ def request_digest(req: dict) -> str:
63
+ """The request digest of a Run request or a session call, from its JSON form.
64
+ The trace ID and the session ID are left out (record.RunRequestDigest and
65
+ record.SessionRunRequestDigest)."""
66
+ m = Msg(req, "request")
67
+ protocol = m.get_int("protocol", UINT32)
68
+ e = _Encoder("plimsoll.run-request.v2" if protocol >= 2 else "plimsoll.run-request.v1")
69
+ e.int("protocol", protocol)
70
+ e.str("minimum_isolation", m.get_str("minimumIsolation"))
71
+ e.int("timeout_ms", m.get_int("timeoutMs", INT32))
72
+ if protocol >= 2:
73
+ rule = m.get_msg("softwareRule")
74
+ ids = rule.get_strs("identities") if rule else []
75
+ e.str("software_mode", rule.get_str("mode") if rule else "")
76
+ e.int("software_identities", len(ids))
77
+ for i in ids:
78
+ e.str("software_identity", i)
79
+ js, project, module, cell = m.get_msg("javascript"), m.get_msg("project"), m.get_msg("module"), m.get_msg("cell")
80
+ if js is not None:
81
+ e.str("kind", "javascript")
82
+ e.str("code", js.get_str("code"))
83
+ e.str("grant_profile", js.get_str("grantProfile"))
84
+ elif cell is not None:
85
+ e.str("kind", "cell")
86
+ e.str("language", cell.get_str("language"))
87
+ e.str("code", cell.get_str("code"))
88
+ files = cell.get_msgs("files")
89
+ e.int("files", len(files))
90
+ for f in files:
91
+ e.str("file_path", f.get_str("path"))
92
+ e.str("file_content", f.get_str("content"))
93
+ elif project is not None:
94
+ e.str("kind", "project")
95
+ e.str("grant_profile", project.get_str("grantProfile"))
96
+ files = project.get_msgs("files")
97
+ e.int("files", len(files))
98
+ for f in files:
99
+ e.str("file_path", f.get_str("path"))
100
+ e.str("file_content", f.get_str("content"))
101
+ steps = project.get_strs("steps")
102
+ e.int("steps", len(steps))
103
+ for s in steps:
104
+ e.str("step_command", s)
105
+ artifacts = project.get_strs("artifacts")
106
+ e.int("artifacts", len(artifacts))
107
+ for a in artifacts:
108
+ e.str("artifact_path", a)
109
+ elif module is not None:
110
+ e.str("kind", "module")
111
+ e.str("model", module.get_str("model"))
112
+ e.floats("end_time", [module.get_float("endTime")])
113
+ e.floats("step", [module.get_float("step")])
114
+ rows = module.get_msgs("rows")
115
+ e.int("rows", len(rows))
116
+ for r in rows:
117
+ e.floats("row_values", r.get_floats("values"))
118
+ else:
119
+ e.str("kind", "")
120
+ return e.sum()
121
+
122
+
123
+ def result_digest(resp: Msg) -> str:
124
+ """The result digest of a RunResponse as received (record.ResultDigest):
125
+ durations, advice and the evidence fields are left out."""
126
+ e = _Encoder("plimsoll.run-result.v1")
127
+ js, project, module, cell = resp.get_msg("javascript"), resp.get_msg("project"), resp.get_msg("module"), resp.get_msg("cell")
128
+ if cell is not None:
129
+ e.str("kind", "cell")
130
+ e.int("exit_code", cell.get_int("exitCode", INT32))
131
+ e.bool("timed_out", cell.get_bool("timedOut"))
132
+ e.bytes("stdout", cell.get_bytes("stdout"))
133
+ e.bytes("stderr", cell.get_bytes("stderr"))
134
+ e.bool("stdout_truncated", cell.get_bool("stdoutTruncated"))
135
+ e.bool("stderr_truncated", cell.get_bool("stderrTruncated"))
136
+ e.bool("interpreter_started", cell.get_bool("interpreterStarted"))
137
+ e.bool("interpreter_ended", cell.get_bool("interpreterEnded"))
138
+ elif js is not None:
139
+ e.str("kind", "javascript")
140
+ e.int("exit_code", js.get_int("exitCode", INT32))
141
+ e.bool("timed_out", js.get_bool("timedOut"))
142
+ e.bytes("stdout", js.get_bytes("stdout"))
143
+ e.bytes("stderr", js.get_bytes("stderr"))
144
+ e.bool("stdout_truncated", js.get_bool("stdoutTruncated"))
145
+ e.bool("stderr_truncated", js.get_bool("stderrTruncated"))
146
+ elif project is not None:
147
+ e.str("kind", "project")
148
+ e.int("outcome", project.get_enum("outcome", OUTCOMES))
149
+ e.str("outcome_detail", project.get_str("outcomeDetail"))
150
+ e.bool("artifacts_truncated", project.get_bool("artifactsTruncated"))
151
+ steps = project.get_msgs("steps")
152
+ e.int("steps", len(steps))
153
+ for s in steps:
154
+ e.str("step_command", s.get_str("command"))
155
+ e.int("step_exit_code", s.get_int("exitCode", INT32))
156
+ e.bool("step_timed_out", s.get_bool("timedOut"))
157
+ e.bytes("step_stdout", s.get_bytes("stdout"))
158
+ e.bytes("step_stderr", s.get_bytes("stderr"))
159
+ e.bool("step_stdout_truncated", s.get_bool("stdoutTruncated"))
160
+ e.bool("step_stderr_truncated", s.get_bool("stderrTruncated"))
161
+ artifacts = project.get_msgs("artifacts")
162
+ e.int("artifacts", len(artifacts))
163
+ for a in artifacts:
164
+ e.str("artifact_path", a.get_str("path"))
165
+ e.bytes("artifact_content", a.get_bytes("content"))
166
+ elif module is not None:
167
+ e.str("kind", "module")
168
+ e.int("outcome", module.get_enum("outcome", OUTCOMES))
169
+ e.str("outcome_detail", module.get_str("outcomeDetail"))
170
+ e.int("width", module.get_int("width", INT32))
171
+ e.bytes("stdout", module.get_bytes("stdout"))
172
+ e.bytes("stderr", module.get_bytes("stderr"))
173
+ runs = module.get_msgs("runs")
174
+ e.int("runs", len(runs))
175
+ for run in runs:
176
+ e.int("run_status", run.get_int("status", INT32))
177
+ e.floats("run_outputs", run.get_floats("outputs"))
178
+ else:
179
+ e.str("kind", "")
180
+ return e.sum()
181
+
182
+
183
+ def record_digest(r: RunRecord) -> str:
184
+ """A record's own digest over every field but ``sha256`` (record.Digest). The
185
+ version is the domain's suffix."""
186
+ e = _Encoder("plimsoll.run-record.v%d" % r.version)
187
+ e.str("request_sha256", r.request_sha256)
188
+ e.str("result_sha256", r.result_sha256)
189
+ if r.version >= 3:
190
+ e.str("unanswered", r.unanswered)
191
+ e.str("provider", r.provider)
192
+ e.str("isolation", r.isolation)
193
+ e.str("environment", r.environment)
194
+ e.str("policy", r.policy)
195
+ if r.version >= 2:
196
+ e.str("software_identity", r.software_identity)
197
+ e.str("software_rule_id", r.software_rule_id)
198
+ e.int("started_unix_ms", r.started_unix_ms)
199
+ e.int("ended_unix_ms", r.ended_unix_ms)
200
+ e.str("session", r.session)
201
+ e.int("sequence", r.sequence)
202
+ e.str("previous_sha256", r.previous_sha256)
203
+ return e.sum()
204
+
205
+
206
+ def session_fingerprint(session_id: str) -> str:
207
+ """The SHA-256 of a session ID, which a session call's record carries instead
208
+ of the ID (record.SessionFingerprint)."""
209
+ return hashlib.sha256(session_id.encode("utf-8")).hexdigest()
210
+
211
+
212
+ def record_from_wire(m: Msg) -> RunRecord:
213
+ return RunRecord(
214
+ version=m.get_int("version", UINT32),
215
+ request_sha256=m.get_str("requestSha256"),
216
+ result_sha256=m.get_str("resultSha256"),
217
+ provider=m.get_str("provider"),
218
+ isolation=m.get_str("isolation"),
219
+ environment=m.get_str("environment"),
220
+ policy=m.get_str("policy"),
221
+ software_identity=m.get_str("softwareIdentity"),
222
+ software_rule_id=m.get_str("softwareRuleId"),
223
+ started_unix_ms=m.get_int("startedUnixMs", INT64),
224
+ ended_unix_ms=m.get_int("endedUnixMs", INT64),
225
+ session=m.get_str("session"),
226
+ sequence=m.get_int("sequence", UINT64),
227
+ previous_sha256=m.get_str("previousSha256"),
228
+ sha256=m.get_str("recordSha256"),
229
+ unanswered=m.get_str("unanswered"),
230
+ )
231
+
232
+
233
+ def check(req_digest: str, protocol: int, rule: Optional[SoftwareRule], resp: Msg) -> RunRecord:
234
+ """Everything a record states that the caller can recompute (record.check):
235
+ the version, both digests, the evidence the response repeats, the software
236
+ rule and the record's own digest. The rule was validated before sending."""
237
+ rec_msg = resp.get_msg("record")
238
+ if rec_msg is None:
239
+ raise NoRecordError("plimsoll: the response carries no run record")
240
+ r = record_from_wire(rec_msg)
241
+ result = result_digest(resp)
242
+ if r.version not in (1, VERSION):
243
+ raise RecordVersionError(f"plimsoll: unknown run record version {r.version} (this client knows 1 and {VERSION})")
244
+ if protocol >= 2 and r.version != VERSION:
245
+ raise RecordVersionError(f"plimsoll: protocol {protocol} requires record version {VERSION}, the record is version {r.version}")
246
+ if r.version == 1 and (r.software_identity or r.software_rule_id):
247
+ raise RecordMismatchError("plimsoll: a version 1 record cannot carry software admission fields")
248
+ if r.unanswered:
249
+ raise RecordMismatchError(f"plimsoll: an answered call's record states an unanswered code, which version {r.version} does not cover")
250
+ if r.request_sha256 != req_digest:
251
+ raise RecordMismatchError(f"plimsoll: request digest {r.request_sha256}, the request sent digests to {req_digest}")
252
+ if r.result_sha256 != result:
253
+ raise RecordMismatchError(f"plimsoll: result digest {r.result_sha256}, the result received digests to {result}")
254
+ if r.provider != resp.get_str("sandbox") or r.isolation != resp.get_str("isolation"):
255
+ raise RecordMismatchError(
256
+ f"plimsoll: the record names {r.provider} at {r.isolation!r}, the response {resp.get_str('sandbox')} at {resp.get_str('isolation')!r}"
257
+ )
258
+ if r.version >= 2 and r.environment != resp.get_str("environment"):
259
+ raise RecordMismatchError("plimsoll: the record's environment differs from the response's")
260
+ if r.version >= 2:
261
+ rule_id = rule.rule_id() if rule is not None else ""
262
+ allowed = rule.allows(r.software_identity) if rule is not None else True
263
+ if r.software_identity != resp.get_str("softwareIdentity") or r.software_rule_id != rule_id or not allowed:
264
+ raise RecordMismatchError("plimsoll: the selected software or admission rule differs from the request and response")
265
+ own = record_digest(r)
266
+ if r.sha256 != own:
267
+ raise RecordMismatchError(f"plimsoll: record digest {r.sha256}, its fields digest to {own}")
268
+ return r
269
+
270
+
271
+ def check_unanswered(req_digest: str, floor: str, rule: Optional[SoftwareRule], m: Msg) -> RunRecord:
272
+ """The record an unanswered session call's error carries (record.CheckUnanswered,
273
+ without the chain, which the Session checks): version 3, no result digest, a
274
+ Connect code, the digest of the request sent, its software rule and that its
275
+ software is in it, the record's own digest, and its tier against the call's floor."""
276
+ r = record_from_wire(m)
277
+ if r.version != UNANSWERED_VERSION:
278
+ raise RecordVersionError(f"plimsoll: an unanswered call's record is version {r.version}, not {UNANSWERED_VERSION}")
279
+ if r.result_sha256 or r.unanswered not in _UNANSWERED_CODES:
280
+ raise RecordMismatchError("plimsoll: an unanswered call's record states a result or no error code")
281
+ if r.request_sha256 != req_digest:
282
+ raise RecordMismatchError(f"plimsoll: request digest {r.request_sha256}, the request sent digests to {req_digest}")
283
+ if r.software_rule_id != (rule.rule_id() if rule is not None else "") or (rule is not None and not rule.allows(r.software_identity)):
284
+ raise RecordMismatchError("plimsoll: the record's software rule is not the request's, or its software is not in it")
285
+ own = record_digest(r)
286
+ if r.sha256 != own:
287
+ raise RecordMismatchError(f"plimsoll: record digest {r.sha256}, its fields digest to {own}")
288
+ if floor and not meets(r.isolation, floor):
289
+ raise IsolationEvidenceMismatchError(
290
+ f"plimsoll: the unanswered call's record states {r.isolation or 'unknown'}, below the call's minimum {floor}; execution may have occurred"
291
+ )
292
+ return r
293
+
294
+
295
+ def check_single(req_digest: str, protocol: int, rule: Optional[SoftwareRule], resp: Msg) -> RunRecord:
296
+ """check, for a single run: a single run's record carries no session fields (record.Check)."""
297
+ r = check(req_digest, protocol, rule, resp)
298
+ if r.session or r.sequence or r.previous_sha256:
299
+ raise RecordMismatchError("plimsoll: a single run's record carries session fields")
300
+ return r
@@ -0,0 +1,149 @@
1
+ """One unary Connect call with the JSON codec, over HTTP/1.1.
2
+
3
+ It uses http.client (the layer urllib.request is built on) directly, for three
4
+ reasons: a redirect is never followed (a Connect call has none, and following one
5
+ would carry the bearer token and the code to another address); proxy settings in
6
+ the environment are not consulted; and the request timeout bounds the whole
7
+ exchange, not each socket read, so a daemon that trickles its answer a byte at a
8
+ time cannot hold the caller past it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import http.client
14
+ import re
15
+ import socket
16
+ import ssl
17
+ import threading
18
+ import time
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+ from urllib.parse import urlsplit
21
+
22
+ from ._version import __version__
23
+ from ._wire import Msg, dumps, error_from_wire, parse_response
24
+ from .errors import MalformedResponseError, RequestTimeoutError, ResponseTooLargeError, TransportError
25
+
26
+ SERVICE = "plimsoll.v1.SandboxService"
27
+
28
+ MAX_RESPONSE_BYTES = 32 << 20
29
+ """Above the largest legitimate project answer (bounded step output plus the
30
+ aggregate artifact cap), so a misbehaving daemon cannot make the client buffer
31
+ without bound: the Go client's maxResponseBytes."""
32
+
33
+ _CHUNK = 1 << 16
34
+
35
+ _DIGITS = re.compile(r"[0-9]+")
36
+
37
+
38
+ class Transport:
39
+ def __init__(self, base_url: str, token: Optional[str], timeout: float, ssl_context: Optional[ssl.SSLContext]) -> None:
40
+ u = urlsplit(base_url)
41
+ self._https = u.scheme == "https"
42
+ self._host = u.hostname or ""
43
+ self._port = u.port
44
+ self._prefix = u.path.rstrip("/")
45
+ self._token = token
46
+ self._timeout = timeout
47
+ self._ssl = ssl_context if ssl_context is not None else (ssl.create_default_context() if self._https else None)
48
+
49
+ def _connection(self) -> http.client.HTTPConnection:
50
+ if self._https:
51
+ return http.client.HTTPSConnection(self._host, self._port, timeout=self._timeout, context=self._ssl)
52
+ return http.client.HTTPConnection(self._host, self._port, timeout=self._timeout)
53
+
54
+ def call(self, method: str, body: Dict[str, Any], path: str) -> Msg:
55
+ """POSTs body to the procedure and returns the answer as a message. A
56
+ Connect error answer raises the restored error; anything that is not a
57
+ Connect answer raises TransportError."""
58
+ data = dumps(body)
59
+ headers = {
60
+ "Content-Type": "application/json",
61
+ "Connect-Protocol-Version": "1",
62
+ "Accept-Encoding": "identity",
63
+ "User-Agent": f"plimsoll-client-python/{__version__}",
64
+ }
65
+ if self._token:
66
+ headers["Authorization"] = f"Bearer {self._token}"
67
+ deadline = time.monotonic() + self._timeout
68
+ conn = self._connection()
69
+ fired = threading.Event()
70
+ timer: Optional[threading.Timer] = None
71
+ try:
72
+ try:
73
+ conn.connect()
74
+ # The socket timeout bounds each read; the timer bounds them all,
75
+ # by shutting the socket down when the deadline passes.
76
+ timer = threading.Timer(max(deadline - time.monotonic(), 0.0), _abort, (conn, fired))
77
+ timer.daemon = True
78
+ timer.start()
79
+ try:
80
+ conn.request("POST", f"{self._prefix}/{SERVICE}/{method}", body=data, headers=headers)
81
+ except (BrokenPipeError, ConnectionResetError):
82
+ # The daemon can answer before the whole body is sent (a refusal
83
+ # needs no payload) and close its end; its answer, which may say
84
+ # nothing ran, is read below instead of being lost to the write.
85
+ if fired.is_set():
86
+ raise
87
+ _settimeout(conn, deadline)
88
+ resp = conn.getresponse()
89
+ raw, complete = _read_capped(conn, resp, deadline)
90
+ # The timer shuts the socket down, which ends a read early: an answer
91
+ # cut short is a timeout, but one read to its stated end is the answer.
92
+ if fired.is_set() and not complete:
93
+ raise socket.timeout("deadline passed")
94
+ except (socket.timeout, TimeoutError):
95
+ raise RequestTimeoutError(f"plimsoll: {method} did not finish within {self._timeout} s") from None
96
+ except (OSError, http.client.HTTPException) as e:
97
+ if fired.is_set():
98
+ raise RequestTimeoutError(f"plimsoll: {method} did not finish within {self._timeout} s") from None
99
+ raise TransportError(f"plimsoll: {method}: {e}") from e
100
+ finally:
101
+ if timer is not None:
102
+ timer.cancel()
103
+ conn.close()
104
+ if resp.status != 200:
105
+ raise error_from_wire(resp.status, raw)
106
+ ctype = (resp.getheader("Content-Type") or "").split(";", 1)[0].strip().lower()
107
+ if ctype != "application/json":
108
+ raise MalformedResponseError(f"plimsoll: {method} answered with content type {ctype or 'none'!r}, not application/json")
109
+ return parse_response(raw, path)
110
+
111
+
112
+ def _abort(conn: http.client.HTTPConnection, fired: threading.Event) -> None:
113
+ fired.set()
114
+ sock = conn.sock
115
+ if sock is not None:
116
+ try:
117
+ sock.shutdown(socket.SHUT_RDWR)
118
+ except OSError:
119
+ pass
120
+
121
+
122
+ def _settimeout(conn: http.client.HTTPConnection, deadline: float) -> None:
123
+ remaining = deadline - time.monotonic()
124
+ if remaining <= 0:
125
+ raise socket.timeout("deadline passed")
126
+ if conn.sock is not None:
127
+ conn.sock.settimeout(remaining)
128
+
129
+
130
+ def _read_capped(conn: http.client.HTTPConnection, resp: http.client.HTTPResponse, deadline: float) -> Tuple[bytes, bool]:
131
+ """The answer's body, and whether it is known complete: read to the length its
132
+ Content-Length states."""
133
+ header = (resp.getheader("Content-Length") or "").strip()
134
+ # ASCII digits only: str.isdigit() also accepts "²", which int() refuses.
135
+ length = int(header) if _DIGITS.fullmatch(header) else None
136
+ if length is not None and length > MAX_RESPONSE_BYTES:
137
+ raise ResponseTooLargeError(f"plimsoll: the daemon's answer is {length} bytes, over the {MAX_RESPONSE_BYTES} byte limit")
138
+ chunks: List[bytes] = []
139
+ n = 0
140
+ while True:
141
+ _settimeout(conn, deadline)
142
+ chunk = resp.read1(_CHUNK) # one read at most, so the deadline is checked between reads
143
+ if not chunk:
144
+ break
145
+ n += len(chunk)
146
+ if n > MAX_RESPONSE_BYTES:
147
+ raise ResponseTooLargeError(f"plimsoll: the daemon's answer exceeds {MAX_RESPONSE_BYTES} bytes")
148
+ chunks.append(chunk)
149
+ return b"".join(chunks), length is not None and n == length