agent2learn 0.1.2__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.
- agent2learn/__init__.py +3 -0
- agent2learn/_release.py +19 -0
- agent2learn/aipolicy.py +182 -0
- agent2learn/api.py +590 -0
- agent2learn/audit.py +358 -0
- agent2learn/auth/__init__.py +282 -0
- agent2learn/auth/cdp.py +1067 -0
- agent2learn/auth/paste.py +378 -0
- agent2learn/calendar.py +525 -0
- agent2learn/calibrate.py +347 -0
- agent2learn/check.py +1091 -0
- agent2learn/cli.py +2039 -0
- agent2learn/clock.py +39 -0
- agent2learn/config.py +205 -0
- agent2learn/console.py +229 -0
- agent2learn/convert.py +1223 -0
- agent2learn/doctor.py +1167 -0
- agent2learn/errors.py +32 -0
- agent2learn/ground.py +735 -0
- agent2learn/index.py +614 -0
- agent2learn/ingest.py +3229 -0
- agent2learn/locations.py +247 -0
- agent2learn/outlines.py +754 -0
- agent2learn/paths.py +683 -0
- agent2learn/pipeline.py +392 -0
- agent2learn/privacy.py +1123 -0
- agent2learn/schools/__init__.py +29 -0
- agent2learn/schools/_base.py +194 -0
- agent2learn/schools/generic.py +78 -0
- agent2learn/schools/uwaterloo.py +66 -0
- agent2learn/session.py +373 -0
- agent2learn/skills.py +1081 -0
- agent2learn/snapshot.py +399 -0
- agent2learn/submit.py +1047 -0
- agent2learn/transactions.py +157 -0
- agent2learn/upgrade.py +288 -0
- agent2learn/vault.py +1134 -0
- agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
- agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
- agent2learn-0.1.2.dist-info/METADATA +186 -0
- agent2learn-0.1.2.dist-info/RECORD +46 -0
- agent2learn-0.1.2.dist-info/WHEEL +4 -0
- agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
- agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
agent2learn/api.py
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""Bounded, first-party D2L HTTP transport.
|
|
2
|
+
|
|
3
|
+
The client is intentionally smaller than a general-purpose HTTP wrapper. It only performs
|
|
4
|
+
same-origin requests, disables automatic redirects, retries idempotent GETs a bounded number of
|
|
5
|
+
times, and leaves completed downloads in the caller's sibling ``.part`` file for the ingest layer
|
|
6
|
+
to validate and install atomically.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import email.utils
|
|
12
|
+
import ipaddress
|
|
13
|
+
import os
|
|
14
|
+
import random
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import stat
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Iterable, Mapping
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from hashlib import sha256
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, BinaryIO
|
|
25
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
26
|
+
|
|
27
|
+
import requests
|
|
28
|
+
from requests import Response
|
|
29
|
+
|
|
30
|
+
from agent2learn import __version__, paths
|
|
31
|
+
from agent2learn.errors import A2LError, SessionExpired
|
|
32
|
+
from agent2learn.schools import School
|
|
33
|
+
from agent2learn.session import Session
|
|
34
|
+
from agent2learn.vault import ManifestEntry
|
|
35
|
+
|
|
36
|
+
THROTTLE = 0.05
|
|
37
|
+
MAX_RETRIES = 5
|
|
38
|
+
CONNECT_TIMEOUT = 10.0
|
|
39
|
+
READ_TIMEOUT = 90.0
|
|
40
|
+
BACKOFF_BASE = 1.0
|
|
41
|
+
MAX_RETRY_AFTER = 60.0
|
|
42
|
+
JITTER_MAX = 0.5
|
|
43
|
+
FREE_DISK_RESERVE = 1 * 1024 * 1024 * 1024
|
|
44
|
+
DEFAULT_MAX_BYTES = 2_147_483_648
|
|
45
|
+
CHUNK_SIZE = 64 * 1024
|
|
46
|
+
DISK_CHECK_EVERY_CHUNKS = 16
|
|
47
|
+
MAX_REDIRECTS = 5
|
|
48
|
+
|
|
49
|
+
_LOGIN_MARKERS = (
|
|
50
|
+
re.compile(r"<title[^>]*>\s*(?:sign\s*in|log\s*in|login)", re.IGNORECASE),
|
|
51
|
+
re.compile(r"<form[^>]+(?:action|id|class)=[^>]*(?:login|signin|d2l)", re.IGNORECASE),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EgressBlocked(A2LError):
|
|
56
|
+
"""A URL or redirect is outside the configured first-party origin."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DownloadError(A2LError):
|
|
60
|
+
"""A first-party response could not be accepted as a complete source file."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DiskSpaceExhausted(DownloadError):
|
|
64
|
+
"""Streaming would consume the configured free-space reserve."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FileTooLarge(DownloadError):
|
|
68
|
+
"""A response advertised or streamed more bytes than the per-file ceiling allows."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class DownloadResult:
|
|
73
|
+
"""A validated response staged in ``temp`` or a conditional not-modified result."""
|
|
74
|
+
|
|
75
|
+
temp: Path | None
|
|
76
|
+
sha256: str | None
|
|
77
|
+
size: int | None
|
|
78
|
+
etag: str | None
|
|
79
|
+
last_modified: str | None
|
|
80
|
+
not_modified: bool
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Client:
|
|
84
|
+
"""A same-origin D2L client carrying one already-harvested local session."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, school: School, session: Session, *, workers: int = 2) -> None:
|
|
87
|
+
if isinstance(workers, bool) or not isinstance(workers, int) or workers < 1:
|
|
88
|
+
raise ValueError("workers must be a positive integer")
|
|
89
|
+
|
|
90
|
+
self.school = school
|
|
91
|
+
self.session = session
|
|
92
|
+
self.workers = workers
|
|
93
|
+
self._base_url = _base_url(school.base_url)
|
|
94
|
+
if _origin(self._base_url) != _origin(session.base_url):
|
|
95
|
+
raise ValueError("school and session must use the same origin")
|
|
96
|
+
|
|
97
|
+
self.lp_version: str | None = None
|
|
98
|
+
self.le_version: str | None = None
|
|
99
|
+
self.download_template: str | None = None
|
|
100
|
+
self._transport = requests.Session()
|
|
101
|
+
# Authenticated LEARN requests must not inherit ambient proxy, netrc, or certificate
|
|
102
|
+
# settings. A caller that wants a proxy must configure an explicit transport rather than
|
|
103
|
+
# silently forwarding session cookies through process-wide environment state.
|
|
104
|
+
self._transport.trust_env = False
|
|
105
|
+
self._transport.cookies.update(session.requests_cookies())
|
|
106
|
+
|
|
107
|
+
def get_json(self, path: str) -> Any:
|
|
108
|
+
"""Perform a JSON GET, translating an HTML login page into ``SessionExpired``."""
|
|
109
|
+
|
|
110
|
+
response = self._request("GET", self._resolve_url(path), stream=False)
|
|
111
|
+
return self._decode_json_response(response)
|
|
112
|
+
|
|
113
|
+
def get_json_once(self, path: str) -> Any:
|
|
114
|
+
"""Perform exactly one JSON GET without following a redirect or retrying a failure.
|
|
115
|
+
|
|
116
|
+
Submission read-back is evidence for an already-attempted mutation, not a best-effort
|
|
117
|
+
fetch. A transient response must therefore remain visible to the caller as unknown rather
|
|
118
|
+
than being retried or redirected into a response that looks successful.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
response = self._request(
|
|
122
|
+
"GET",
|
|
123
|
+
self._resolve_url(path),
|
|
124
|
+
stream=False,
|
|
125
|
+
retries=False,
|
|
126
|
+
follow_redirects=False,
|
|
127
|
+
)
|
|
128
|
+
return self._decode_json_response(response)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _decode_json_response(response: Response) -> Any:
|
|
132
|
+
"""Decode one owned response and always close it."""
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
if _is_login_response(response):
|
|
136
|
+
raise SessionExpired("session expired · run: a2l auth")
|
|
137
|
+
response.raise_for_status()
|
|
138
|
+
try:
|
|
139
|
+
return response.json()
|
|
140
|
+
except ValueError as exc:
|
|
141
|
+
raise DownloadError("response was not valid JSON") from exc
|
|
142
|
+
finally:
|
|
143
|
+
response.close()
|
|
144
|
+
|
|
145
|
+
def download(
|
|
146
|
+
self,
|
|
147
|
+
url: str,
|
|
148
|
+
temp: Path,
|
|
149
|
+
*,
|
|
150
|
+
prior: ManifestEntry | None = None,
|
|
151
|
+
max_bytes: int | None = DEFAULT_MAX_BYTES,
|
|
152
|
+
is_html_topic: bool = False,
|
|
153
|
+
root: Path | None = None,
|
|
154
|
+
) -> DownloadResult:
|
|
155
|
+
"""Stream one first-party source into ``temp`` and validate it before returning.
|
|
156
|
+
|
|
157
|
+
``temp`` is deliberately the caller's unique sibling ``.part`` path. This layer never
|
|
158
|
+
installs it into a materialized destination or writes a manifest entry. Failed or
|
|
159
|
+
incomplete transfers remove the part; a successful transfer leaves it for the ingest
|
|
160
|
+
layer to fsync/install through the shared atomic primitive.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
if max_bytes is not None and (
|
|
164
|
+
isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0
|
|
165
|
+
):
|
|
166
|
+
raise ValueError("max_bytes must be a positive integer or None")
|
|
167
|
+
_validate_part_path(temp)
|
|
168
|
+
paths.ensure_dir(temp.parent, root=root)
|
|
169
|
+
if root is not None and paths.has_link_component(temp.parent, root=root):
|
|
170
|
+
raise ValueError("download temp parent contains a link component")
|
|
171
|
+
|
|
172
|
+
request_headers: dict[str, str] = {}
|
|
173
|
+
if prior is not None:
|
|
174
|
+
if prior.etag:
|
|
175
|
+
request_headers["If-None-Match"] = prior.etag
|
|
176
|
+
if prior.last_modified:
|
|
177
|
+
request_headers["If-Modified-Since"] = prior.last_modified
|
|
178
|
+
|
|
179
|
+
response: Response | None = None
|
|
180
|
+
try:
|
|
181
|
+
# Jitter staggers the start of concurrent download workers. The shared throttle in
|
|
182
|
+
# _request applies after each successful response as well.
|
|
183
|
+
time.sleep(random.uniform(0.0, JITTER_MAX))
|
|
184
|
+
response = self._request(
|
|
185
|
+
"GET",
|
|
186
|
+
self._resolve_url(url),
|
|
187
|
+
headers=request_headers,
|
|
188
|
+
stream=True,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if response.status_code == 304:
|
|
192
|
+
if prior is None:
|
|
193
|
+
raise DownloadError("304 response has no prior manifest entry")
|
|
194
|
+
_remove_part(temp, root=root)
|
|
195
|
+
return DownloadResult(
|
|
196
|
+
temp=None,
|
|
197
|
+
sha256=prior.sha256,
|
|
198
|
+
size=prior.size,
|
|
199
|
+
etag=response.headers.get("ETag") or prior.etag,
|
|
200
|
+
last_modified=response.headers.get("Last-Modified") or prior.last_modified,
|
|
201
|
+
not_modified=True,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
content_type = response.headers.get("Content-Type", "").casefold()
|
|
205
|
+
html_response = "text/html" in content_type
|
|
206
|
+
# _is_login_response inspects response.text, which buffers a streamed response. A
|
|
207
|
+
# legitimate HTML topic is allowed through here and checked from the bounded probe
|
|
208
|
+
# below instead, so a large HTML topic is not read into memory twice.
|
|
209
|
+
if not (
|
|
210
|
+
is_html_topic and html_response and response.status_code not in {401, 403}
|
|
211
|
+
) and _is_login_response(response):
|
|
212
|
+
raise SessionExpired("session expired · run: a2l auth")
|
|
213
|
+
response.raise_for_status()
|
|
214
|
+
|
|
215
|
+
if html_response and not is_html_topic:
|
|
216
|
+
raise SessionExpired("session expired · run: a2l auth")
|
|
217
|
+
|
|
218
|
+
advertised_size = _content_length(response)
|
|
219
|
+
if (
|
|
220
|
+
max_bytes is not None
|
|
221
|
+
and advertised_size is not None
|
|
222
|
+
and advertised_size > max_bytes
|
|
223
|
+
):
|
|
224
|
+
raise FileTooLarge("response exceeds the per-file ceiling")
|
|
225
|
+
if advertised_size is not None:
|
|
226
|
+
_ensure_disk_space(temp, advertised_size)
|
|
227
|
+
|
|
228
|
+
digest = sha256()
|
|
229
|
+
size = 0
|
|
230
|
+
chunks_since_disk_check = 0
|
|
231
|
+
html_probe = bytearray()
|
|
232
|
+
with _open_download_part(temp, root=root) as handle:
|
|
233
|
+
try:
|
|
234
|
+
for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
|
|
235
|
+
if not chunk:
|
|
236
|
+
continue
|
|
237
|
+
if max_bytes is not None and size + len(chunk) > max_bytes:
|
|
238
|
+
raise FileTooLarge("response exceeds the per-file ceiling")
|
|
239
|
+
if chunks_since_disk_check == 0:
|
|
240
|
+
_ensure_disk_space(temp, len(chunk))
|
|
241
|
+
if html_response and len(html_probe) < 64 * 1024:
|
|
242
|
+
html_probe.extend(chunk[: 64 * 1024 - len(html_probe)])
|
|
243
|
+
handle.write(chunk)
|
|
244
|
+
digest.update(chunk)
|
|
245
|
+
size += len(chunk)
|
|
246
|
+
chunks_since_disk_check += 1
|
|
247
|
+
if chunks_since_disk_check == DISK_CHECK_EVERY_CHUNKS:
|
|
248
|
+
chunks_since_disk_check = 0
|
|
249
|
+
except requests.RequestException as exc:
|
|
250
|
+
detail = (
|
|
251
|
+
"size validation failed" if advertised_size is not None else "stream failed"
|
|
252
|
+
)
|
|
253
|
+
raise DownloadError(f"download {detail}") from exc
|
|
254
|
+
|
|
255
|
+
if html_response and _looks_like_login(html_probe.decode("utf-8", "ignore")):
|
|
256
|
+
raise SessionExpired("session expired · run: a2l auth")
|
|
257
|
+
if size == 0:
|
|
258
|
+
raise DownloadError("response body is empty")
|
|
259
|
+
encoding = response.headers.get("Content-Encoding", "identity").strip().casefold()
|
|
260
|
+
received = response.raw.tell() if encoding not in {"", "identity"} else size
|
|
261
|
+
if advertised_size is not None and received != advertised_size:
|
|
262
|
+
raise DownloadError(
|
|
263
|
+
f"response size mismatch: advertised {advertised_size}, received {received}"
|
|
264
|
+
)
|
|
265
|
+
handle.flush()
|
|
266
|
+
os.fsync(handle.fileno())
|
|
267
|
+
|
|
268
|
+
return DownloadResult(
|
|
269
|
+
temp=temp,
|
|
270
|
+
sha256=digest.hexdigest(),
|
|
271
|
+
size=size,
|
|
272
|
+
etag=response.headers.get("ETag"),
|
|
273
|
+
last_modified=response.headers.get("Last-Modified"),
|
|
274
|
+
not_modified=False,
|
|
275
|
+
)
|
|
276
|
+
except BaseException:
|
|
277
|
+
_remove_part(temp, root=root)
|
|
278
|
+
raise
|
|
279
|
+
finally:
|
|
280
|
+
if response is not None:
|
|
281
|
+
response.close()
|
|
282
|
+
|
|
283
|
+
def post_once(
|
|
284
|
+
self,
|
|
285
|
+
path: str,
|
|
286
|
+
body: bytes | Iterable[bytes],
|
|
287
|
+
*,
|
|
288
|
+
content_type: str,
|
|
289
|
+
) -> Response:
|
|
290
|
+
"""Send exactly one mutating POST with an explicit length and no transport retry.
|
|
291
|
+
|
|
292
|
+
The caller owns the decision to mutate anything on LEARN, so this deliberately exposes no
|
|
293
|
+
retry, no redirect replay, and no endpoint fallback: a transient failure returns to the
|
|
294
|
+
caller with the request having been attempted exactly once.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
content_length: int
|
|
298
|
+
if isinstance(body, bytes):
|
|
299
|
+
content_length = len(body)
|
|
300
|
+
else:
|
|
301
|
+
candidate: object = getattr(body, "content_length", None)
|
|
302
|
+
if isinstance(candidate, bool) or not isinstance(candidate, int) or candidate < 0:
|
|
303
|
+
raise ValueError(
|
|
304
|
+
"streaming submission body must expose a non-negative content_length"
|
|
305
|
+
)
|
|
306
|
+
content_length = candidate
|
|
307
|
+
return self._request(
|
|
308
|
+
"POST",
|
|
309
|
+
path,
|
|
310
|
+
headers={"Content-Type": content_type, "Content-Length": str(content_length)},
|
|
311
|
+
stream=False,
|
|
312
|
+
mutating=True,
|
|
313
|
+
data=body,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _request(
|
|
317
|
+
self,
|
|
318
|
+
method: str,
|
|
319
|
+
url: str,
|
|
320
|
+
*,
|
|
321
|
+
headers: Mapping[str, str] | None = None,
|
|
322
|
+
stream: bool,
|
|
323
|
+
mutating: bool = False,
|
|
324
|
+
data: bytes | Iterable[bytes] | None = None,
|
|
325
|
+
retries: bool = True,
|
|
326
|
+
follow_redirects: bool = True,
|
|
327
|
+
) -> Response:
|
|
328
|
+
"""Issue a request with explicit redirects, retry, timeout, and egress policy."""
|
|
329
|
+
|
|
330
|
+
method = method.upper()
|
|
331
|
+
target = self._resolve_url(url)
|
|
332
|
+
merged_headers = {
|
|
333
|
+
"User-Agent": f"agent2learn/{__version__} (+https://github.com/ManagementMO/agent2learn)",
|
|
334
|
+
}
|
|
335
|
+
if self.session.xsrf:
|
|
336
|
+
merged_headers["X-Csrf-Token"] = self.session.xsrf
|
|
337
|
+
if headers:
|
|
338
|
+
merged_headers.update(headers)
|
|
339
|
+
|
|
340
|
+
request_is_mutating = mutating or method in {"POST", "PUT", "PATCH", "DELETE"}
|
|
341
|
+
retryable = retries and method == "GET" and not request_is_mutating
|
|
342
|
+
attempt = 1
|
|
343
|
+
redirects = 0
|
|
344
|
+
backoff = BACKOFF_BASE
|
|
345
|
+
visited = {target}
|
|
346
|
+
while True:
|
|
347
|
+
response = self._transport.request(
|
|
348
|
+
method=method,
|
|
349
|
+
url=target,
|
|
350
|
+
headers=merged_headers,
|
|
351
|
+
timeout=(CONNECT_TIMEOUT, READ_TIMEOUT),
|
|
352
|
+
allow_redirects=False,
|
|
353
|
+
stream=stream,
|
|
354
|
+
data=data,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if response.status_code == 304:
|
|
358
|
+
time.sleep(THROTTLE)
|
|
359
|
+
return response
|
|
360
|
+
|
|
361
|
+
if 300 <= response.status_code < 400:
|
|
362
|
+
location = response.headers.get("Location")
|
|
363
|
+
response.close()
|
|
364
|
+
if not location:
|
|
365
|
+
raise DownloadError("redirect response has no Location")
|
|
366
|
+
if request_is_mutating:
|
|
367
|
+
# Never replay a submission body automatically. The caller must inspect the
|
|
368
|
+
# redirect and decide explicitly; this avoids a silent second POST to D2L.
|
|
369
|
+
raise EgressBlocked("mutating request redirect requires caller decision")
|
|
370
|
+
if not follow_redirects:
|
|
371
|
+
# A one-shot read-back must prove the named endpoint responded. Treat a
|
|
372
|
+
# redirect as unknown instead of silently substituting a different route.
|
|
373
|
+
raise EgressBlocked("one-shot GET redirect requires caller decision")
|
|
374
|
+
redirects += 1
|
|
375
|
+
if redirects > MAX_REDIRECTS:
|
|
376
|
+
raise EgressBlocked("redirect limit exceeded")
|
|
377
|
+
next_target = self._resolve_url(urljoin(target, location))
|
|
378
|
+
if next_target in visited:
|
|
379
|
+
raise EgressBlocked("redirect loop rejected")
|
|
380
|
+
visited.add(next_target)
|
|
381
|
+
target = next_target
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
is_transient = response.status_code == 429 or 500 <= response.status_code <= 599
|
|
385
|
+
if retryable and is_transient and attempt < MAX_RETRIES:
|
|
386
|
+
delay = _retry_delay(response, backoff)
|
|
387
|
+
response.close()
|
|
388
|
+
time.sleep(delay)
|
|
389
|
+
backoff = min(backoff * 2.0, MAX_RETRY_AFTER)
|
|
390
|
+
attempt += 1
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
if 200 <= response.status_code < 300:
|
|
394
|
+
time.sleep(THROTTLE)
|
|
395
|
+
return response
|
|
396
|
+
|
|
397
|
+
def _resolve_url(self, value: str) -> str:
|
|
398
|
+
if not isinstance(value, str) or not value:
|
|
399
|
+
raise EgressBlocked("request URL must be a non-empty string")
|
|
400
|
+
candidate = urljoin(self._base_url.rstrip("/") + "/", value)
|
|
401
|
+
normalized = _request_url(candidate)
|
|
402
|
+
if _origin(normalized) != _origin(self._base_url):
|
|
403
|
+
raise EgressBlocked("request target is outside the configured LEARN origin")
|
|
404
|
+
return normalized
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _base_url(value: str) -> str:
|
|
408
|
+
normalized = _request_url(value)
|
|
409
|
+
parsed = urlsplit(normalized)
|
|
410
|
+
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _request_url(value: str) -> str:
|
|
414
|
+
try:
|
|
415
|
+
parsed = urlsplit(value)
|
|
416
|
+
if parsed.scheme.casefold() not in {"http", "https"}:
|
|
417
|
+
raise ValueError
|
|
418
|
+
if parsed.hostname is None or parsed.username is not None or parsed.password is not None:
|
|
419
|
+
raise ValueError
|
|
420
|
+
port = parsed.port # Access validates malformed ports before a request reaches requests.
|
|
421
|
+
if port is not None and not 1 <= port <= 65535:
|
|
422
|
+
raise ValueError
|
|
423
|
+
except (TypeError, ValueError) as exc:
|
|
424
|
+
raise EgressBlocked("request URL is not a safe HTTP origin") from exc
|
|
425
|
+
path = parsed.path or "/"
|
|
426
|
+
return urlunsplit((parsed.scheme.casefold(), parsed.netloc, path, parsed.query, ""))
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _origin(value: str) -> tuple[str, str, int]:
|
|
430
|
+
parsed = urlsplit(value)
|
|
431
|
+
hostname = parsed.hostname
|
|
432
|
+
if hostname is None:
|
|
433
|
+
raise EgressBlocked("request URL has no hostname")
|
|
434
|
+
try:
|
|
435
|
+
try:
|
|
436
|
+
host = ipaddress.ip_address(hostname).compressed.casefold()
|
|
437
|
+
except ValueError:
|
|
438
|
+
host = hostname.encode("idna").decode("ascii").casefold().rstrip(".")
|
|
439
|
+
port = parsed.port or (443 if parsed.scheme.casefold() == "https" else 80)
|
|
440
|
+
except (UnicodeError, ValueError) as exc:
|
|
441
|
+
raise EgressBlocked("request URL has an invalid origin") from exc
|
|
442
|
+
return parsed.scheme.casefold(), host, port
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _content_length(response: Response) -> int | None:
|
|
446
|
+
value = response.headers.get("Content-Length")
|
|
447
|
+
if value is None:
|
|
448
|
+
return None
|
|
449
|
+
try:
|
|
450
|
+
length = int(value)
|
|
451
|
+
except (TypeError, ValueError) as exc:
|
|
452
|
+
raise DownloadError("response Content-Length is invalid") from exc
|
|
453
|
+
if length < 0:
|
|
454
|
+
raise DownloadError("response Content-Length is invalid")
|
|
455
|
+
return length
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _ensure_disk_space(path: Path, required: int) -> None:
|
|
459
|
+
usage = shutil.disk_usage(os.fspath(paths.long_path(path.parent)))
|
|
460
|
+
if usage.free < FREE_DISK_RESERVE + required:
|
|
461
|
+
raise DiskSpaceExhausted("free disk space would cross the configured reserve")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _retry_delay(response: Response, backoff: float) -> float:
|
|
465
|
+
value = response.headers.get("Retry-After") if response.status_code in {429, 503} else None
|
|
466
|
+
delay: float
|
|
467
|
+
if value is not None:
|
|
468
|
+
try:
|
|
469
|
+
delay = max(0.0, float(value))
|
|
470
|
+
except ValueError:
|
|
471
|
+
try:
|
|
472
|
+
retry_at = email.utils.parsedate_to_datetime(value)
|
|
473
|
+
except (TypeError, ValueError, OverflowError):
|
|
474
|
+
delay = backoff
|
|
475
|
+
else:
|
|
476
|
+
if retry_at.tzinfo is None:
|
|
477
|
+
retry_at = retry_at.replace(tzinfo=UTC)
|
|
478
|
+
delay = max(0.0, (retry_at - datetime.now(UTC)).total_seconds())
|
|
479
|
+
else:
|
|
480
|
+
delay = backoff
|
|
481
|
+
return min(delay, MAX_RETRY_AFTER)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _is_login_response(response: Response) -> bool:
|
|
485
|
+
content_type = response.headers.get("Content-Type", "").casefold()
|
|
486
|
+
if "text/html" not in content_type:
|
|
487
|
+
return False
|
|
488
|
+
if response.status_code in {401, 403}:
|
|
489
|
+
return True
|
|
490
|
+
return _looks_like_login(response.text)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _looks_like_login(text: str) -> bool:
|
|
494
|
+
return any(marker.search(text) for marker in _LOGIN_MARKERS)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _validate_part_path(temp: Path) -> None:
|
|
498
|
+
if not isinstance(temp, Path):
|
|
499
|
+
raise TypeError("temp must be a pathlib.Path")
|
|
500
|
+
if not temp.name.endswith(".part"):
|
|
501
|
+
raise ValueError("download temp must be a sibling .part path")
|
|
502
|
+
try:
|
|
503
|
+
file_stat = os.lstat(os.fspath(paths.long_path(temp)))
|
|
504
|
+
except FileNotFoundError:
|
|
505
|
+
return
|
|
506
|
+
if paths.is_link(temp) or stat.S_ISLNK(file_stat.st_mode):
|
|
507
|
+
raise ValueError("download temp must not be a symlink")
|
|
508
|
+
if not stat.S_ISREG(file_stat.st_mode):
|
|
509
|
+
raise ValueError("download temp must be a regular file")
|
|
510
|
+
if getattr(file_stat, "st_nlink", 1) != 1:
|
|
511
|
+
raise ValueError("download temp must not be a hard link")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _open_download_part(temp: Path, *, root: Path | None = None) -> BinaryIO:
|
|
515
|
+
"""Open a unique part without following a replacement symlink or hard link."""
|
|
516
|
+
# The caller checks the parent before starting the request, but network latency leaves a
|
|
517
|
+
# window in which a trusted parent can be replaced. Revalidate at the actual open boundary;
|
|
518
|
+
# O_NOFOLLOW protects the final component while this check protects the path leading to it.
|
|
519
|
+
if root is not None and paths.has_link_component(temp, root=root):
|
|
520
|
+
raise ValueError("download temp path contains a link component")
|
|
521
|
+
raw_path = os.fspath(paths.long_path(temp))
|
|
522
|
+
try:
|
|
523
|
+
prior_stat = os.lstat(raw_path)
|
|
524
|
+
except FileNotFoundError:
|
|
525
|
+
prior_stat = None
|
|
526
|
+
|
|
527
|
+
flags = os.O_WRONLY | os.O_CREAT
|
|
528
|
+
if prior_stat is None:
|
|
529
|
+
flags |= os.O_EXCL
|
|
530
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
531
|
+
try:
|
|
532
|
+
file_descriptor = os.open(raw_path, flags | no_follow, 0o600)
|
|
533
|
+
except FileExistsError:
|
|
534
|
+
# A caller may have reserved the sibling with mkstemp(). Re-check its identity after
|
|
535
|
+
# the race rather than truncating an object that appeared under the requested name.
|
|
536
|
+
prior_stat = os.lstat(raw_path)
|
|
537
|
+
if paths.is_link(temp) or not stat.S_ISREG(prior_stat.st_mode):
|
|
538
|
+
raise ValueError("download temp must be a regular file") from None
|
|
539
|
+
file_descriptor = os.open(raw_path, os.O_WRONLY | no_follow)
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
opened_stat = os.fstat(file_descriptor)
|
|
543
|
+
if not stat.S_ISREG(opened_stat.st_mode):
|
|
544
|
+
raise ValueError("download temp must be a regular file")
|
|
545
|
+
if getattr(opened_stat, "st_nlink", 1) != 1:
|
|
546
|
+
raise ValueError("download temp must not be a hard link")
|
|
547
|
+
if prior_stat is not None and (
|
|
548
|
+
opened_stat.st_dev != prior_stat.st_dev or opened_stat.st_ino != prior_stat.st_ino
|
|
549
|
+
):
|
|
550
|
+
raise ValueError("download temp changed while it was being opened")
|
|
551
|
+
os.ftruncate(file_descriptor, 0)
|
|
552
|
+
return os.fdopen(file_descriptor, "wb")
|
|
553
|
+
except BaseException:
|
|
554
|
+
os.close(file_descriptor)
|
|
555
|
+
raise
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _remove_part(temp: Path, *, root: Path | None = None) -> None:
|
|
559
|
+
if root is not None:
|
|
560
|
+
try:
|
|
561
|
+
if paths.has_link_component(temp, root=root):
|
|
562
|
+
return
|
|
563
|
+
except (OSError, ValueError):
|
|
564
|
+
return
|
|
565
|
+
try:
|
|
566
|
+
os.unlink(os.fspath(paths.long_path(temp)))
|
|
567
|
+
except OSError:
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
__all__ = [
|
|
572
|
+
"BACKOFF_BASE",
|
|
573
|
+
"CHUNK_SIZE",
|
|
574
|
+
"Client",
|
|
575
|
+
"CONNECT_TIMEOUT",
|
|
576
|
+
"DEFAULT_MAX_BYTES",
|
|
577
|
+
"DISK_CHECK_EVERY_CHUNKS",
|
|
578
|
+
"DiskSpaceExhausted",
|
|
579
|
+
"DownloadError",
|
|
580
|
+
"DownloadResult",
|
|
581
|
+
"EgressBlocked",
|
|
582
|
+
"FileTooLarge",
|
|
583
|
+
"FREE_DISK_RESERVE",
|
|
584
|
+
"JITTER_MAX",
|
|
585
|
+
"MAX_REDIRECTS",
|
|
586
|
+
"MAX_RETRIES",
|
|
587
|
+
"MAX_RETRY_AFTER",
|
|
588
|
+
"READ_TIMEOUT",
|
|
589
|
+
"THROTTLE",
|
|
590
|
+
]
|