sharefetch 1.0.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.
- sharefetch/__init__.py +11 -0
- sharefetch/__main__.py +8 -0
- sharefetch/auth.py +343 -0
- sharefetch/cli.py +782 -0
- sharefetch/config.py +374 -0
- sharefetch/engine.py +1055 -0
- sharefetch/errors.py +57 -0
- sharefetch/events.py +129 -0
- sharefetch/graph.py +474 -0
- sharefetch/gui.py +1170 -0
- sharefetch/hashes.py +123 -0
- sharefetch/listing.py +493 -0
- sharefetch/manifest.py +198 -0
- sharefetch/pacing.py +211 -0
- sharefetch/paths.py +245 -0
- sharefetch/py.typed +0 -0
- sharefetch/resolve.py +660 -0
- sharefetch/state.py +707 -0
- sharefetch/tokencache.py +214 -0
- sharefetch/transfer.py +815 -0
- sharefetch/urls.py +195 -0
- sharefetch/verify.py +378 -0
- sharefetch-1.0.0.dist-info/METADATA +193 -0
- sharefetch-1.0.0.dist-info/RECORD +28 -0
- sharefetch-1.0.0.dist-info/WHEEL +5 -0
- sharefetch-1.0.0.dist-info/entry_points.txt +5 -0
- sharefetch-1.0.0.dist-info/licenses/LICENSE +21 -0
- sharefetch-1.0.0.dist-info/top_level.txt +1 -0
sharefetch/errors.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Exception hierarchy for sharefetch."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SharefetchError(Exception):
|
|
9
|
+
"""Base class for every error this package raises.
|
|
10
|
+
|
|
11
|
+
``status`` carries the HTTP status of the response that produced the error,
|
|
12
|
+
and is ``None`` for every error that did not come from one. It exists on the
|
|
13
|
+
base class because a caller several layers above the socket has to tell two
|
|
14
|
+
refusals apart without reading the text of a message: site resolution probes
|
|
15
|
+
candidate site paths, and a ``404`` there means the path names no site while
|
|
16
|
+
a ``403`` means it names one the account cannot read. The two lead to
|
|
17
|
+
different messages, and only the status separates them.
|
|
18
|
+
|
|
19
|
+
The keyword is optional, so every existing raise site is unchanged and
|
|
20
|
+
records ``None``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, *args: object, status: int | None = None) -> None:
|
|
24
|
+
super().__init__(*args)
|
|
25
|
+
self.status = status
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ValidationError(SharefetchError):
|
|
29
|
+
"""A command-line option, URL or destination failed boundary validation."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthError(SharefetchError):
|
|
33
|
+
"""Device flow declined, expired, or refused by policy."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ResolveError(SharefetchError):
|
|
37
|
+
"""A site, library or folder could not be resolved, or was ambiguous."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EnumerationError(SharefetchError):
|
|
41
|
+
"""A folder listing was refused or malformed."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TransferError(SharefetchError):
|
|
45
|
+
"""One item failed to transfer. The queue continues."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class VerificationError(SharefetchError):
|
|
49
|
+
"""One item failed verification. The queue continues."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class StateError(SharefetchError):
|
|
53
|
+
"""The state file is unreadable in a way reconciliation cannot absorb."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ManifestError(SharefetchError):
|
|
57
|
+
"""The manifest could not be written. The downloaded tree is unaffected."""
|
sharefetch/events.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Event dataclasses and the sink protocol the front ends implement.
|
|
2
|
+
|
|
3
|
+
``engine.run`` emits the events below and nothing else, so the command-line
|
|
4
|
+
front end and the graphical front end observe one run through one vocabulary.
|
|
5
|
+
Each event is frozen, carries only data, and can be queued across threads. The
|
|
6
|
+
module is pure: it opens no socket and reads no clock.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Resolved:
|
|
19
|
+
site_web_url: str
|
|
20
|
+
drive_name: str
|
|
21
|
+
folder_path: str
|
|
22
|
+
drive_id: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class DeviceCodeRequested:
|
|
27
|
+
user_code: str
|
|
28
|
+
verification_uri: str
|
|
29
|
+
message: str
|
|
30
|
+
expires_at: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class DeviceCodeCompleted:
|
|
35
|
+
account: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class EnumerationProgress:
|
|
40
|
+
folders: int
|
|
41
|
+
files: int
|
|
42
|
+
total_bytes: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class EnumerationComplete:
|
|
47
|
+
files: int
|
|
48
|
+
total_bytes: int
|
|
49
|
+
folders: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Reconciled:
|
|
54
|
+
counts: dict[str, int]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class FileStarted:
|
|
59
|
+
drive_path: str
|
|
60
|
+
size: int
|
|
61
|
+
resume_from: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class FileProgress:
|
|
66
|
+
drive_path: str
|
|
67
|
+
bytes_written: int
|
|
68
|
+
size: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class FileFinished:
|
|
73
|
+
drive_path: str
|
|
74
|
+
status: str
|
|
75
|
+
# A verify.VerificationResult, or None where verification did not run. The
|
|
76
|
+
# annotation stays untyped because events depends on no other module.
|
|
77
|
+
verification: Any
|
|
78
|
+
error: str | None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Throttled:
|
|
83
|
+
seconds: float
|
|
84
|
+
reason: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class RunSummary:
|
|
89
|
+
transferred: int
|
|
90
|
+
skipped: int
|
|
91
|
+
failed: int
|
|
92
|
+
already_present: int
|
|
93
|
+
verified: int
|
|
94
|
+
bytes_transferred: int
|
|
95
|
+
manifest_path: Path | None
|
|
96
|
+
hash_algorithm_validated: bool | None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class RunFinished:
|
|
101
|
+
summary: RunSummary
|
|
102
|
+
manifest_path: Path | None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class LogRecord:
|
|
107
|
+
level: str
|
|
108
|
+
message: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
Event = (
|
|
112
|
+
Resolved
|
|
113
|
+
| DeviceCodeRequested
|
|
114
|
+
| DeviceCodeCompleted
|
|
115
|
+
| EnumerationProgress
|
|
116
|
+
| EnumerationComplete
|
|
117
|
+
| Reconciled
|
|
118
|
+
| FileStarted
|
|
119
|
+
| FileProgress
|
|
120
|
+
| FileFinished
|
|
121
|
+
| Throttled
|
|
122
|
+
| RunFinished
|
|
123
|
+
| LogRecord
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@runtime_checkable
|
|
128
|
+
class EventSink(Protocol):
|
|
129
|
+
def emit(self, event: "Event") -> None: ...
|
sharefetch/graph.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""The single HTTP choke point: one session, one pacer, one classification table.
|
|
2
|
+
|
|
3
|
+
Every request the package makes to Microsoft Graph and to the pre-authenticated
|
|
4
|
+
storage host it redirects to passes through :class:`GraphClient`. Concentrating
|
|
5
|
+
them here makes the traffic discipline enforceable: the inter-request delay,
|
|
6
|
+
the byte-rate ceiling, the throttle hold, the retry ceiling and the response
|
|
7
|
+
classification each exist once and cannot be bypassed by a caller that forgets
|
|
8
|
+
them.
|
|
9
|
+
|
|
10
|
+
Four properties hold here and are the reason the module exists.
|
|
11
|
+
|
|
12
|
+
* One pacer. The :class:`~sharefetch.pacing.Pacer` is supplied by the caller and
|
|
13
|
+
shared by every worker, so a ``429`` met by one worker stops both.
|
|
14
|
+
* The bearer token is attached per request and never to the session. The content
|
|
15
|
+
endpoint answers with a redirect to a storage host whose URL carries its own
|
|
16
|
+
pre-authenticated credential in its query string, and ``requests`` drops a
|
|
17
|
+
per-request ``Authorization`` header when a redirect crosses hosts. Sending the
|
|
18
|
+
bearer token to storage can be refused outright. The download-URL fallback
|
|
19
|
+
addresses that host directly, so :meth:`GraphClient.stream`
|
|
20
|
+
takes ``authenticated=False`` and builds no header at all for it.
|
|
21
|
+
* No credential and no non-Graph query string reaches a log. Every log call in
|
|
22
|
+
this module passes a URL through :func:`safe_url` first, and
|
|
23
|
+
:class:`RedactingFilter` is the second line of defence over every handler the
|
|
24
|
+
front ends install.
|
|
25
|
+
* One forced refresh answers a ``401``. A second ``401`` on the same request
|
|
26
|
+
raises :class:`~sharefetch.errors.AuthError`, which is terminal for the run.
|
|
27
|
+
|
|
28
|
+
``sleep`` and ``session`` are injected so the classification table is testable
|
|
29
|
+
without a socket and without elapsing real time.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
import re
|
|
36
|
+
import time
|
|
37
|
+
from collections.abc import Callable
|
|
38
|
+
from typing import Any
|
|
39
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
40
|
+
|
|
41
|
+
import requests
|
|
42
|
+
from requests.adapters import HTTPAdapter
|
|
43
|
+
|
|
44
|
+
from .auth import TokenProvider
|
|
45
|
+
from .config import (
|
|
46
|
+
GRAPH_HOST,
|
|
47
|
+
MAX_ATTEMPTS_PER_FILE,
|
|
48
|
+
REQUEST_TIMEOUT,
|
|
49
|
+
check_connections,
|
|
50
|
+
)
|
|
51
|
+
from .errors import AuthError, EnumerationError, SharefetchError, TransferError
|
|
52
|
+
from .events import EventSink, Throttled
|
|
53
|
+
from .pacing import Pacer, backoff_delay, parse_retry_after
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
# One name per status row, so the classification reads as the table does.
|
|
58
|
+
_STATUS_SUCCESS = frozenset({200, 206})
|
|
59
|
+
_STATUS_OK = 200
|
|
60
|
+
_STATUS_UNAUTHORIZED = 401
|
|
61
|
+
_STATUS_FORBIDDEN = 403
|
|
62
|
+
_STATUS_NOT_FOUND = 404
|
|
63
|
+
_STATUS_RANGE_NOT_SATISFIABLE = 416
|
|
64
|
+
_STATUS_THROTTLED = frozenset({429, 503})
|
|
65
|
+
_STATUS_SERVER_RETRYABLE = frozenset({500, 502, 504})
|
|
66
|
+
|
|
67
|
+
# Transport failures are answered with backoff and a resume by range. A
|
|
68
|
+
# connect timeout is a subclass of both ConnectionError and Timeout, so it is
|
|
69
|
+
# covered. TooManyRedirects is deliberately absent: it is terminal.
|
|
70
|
+
_TRANSPORT_FAILURES = (
|
|
71
|
+
requests.ConnectionError,
|
|
72
|
+
requests.Timeout,
|
|
73
|
+
requests.exceptions.ChunkedEncodingError,
|
|
74
|
+
requests.exceptions.ContentDecodingError,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# A refused item is recorded with the first 400 characters of the body.
|
|
78
|
+
# The byte budget bounds the read, because an error body arrives on a streaming
|
|
79
|
+
# response whose full length is not known in advance.
|
|
80
|
+
_BODY_EXCERPT_CHARS = 400
|
|
81
|
+
_BODY_EXCERPT_BYTES = 4096
|
|
82
|
+
|
|
83
|
+
# Attached to a 403. A successful listing is not evidence that bytes will be
|
|
84
|
+
# served, so the refusal says so rather than leaving the operator to infer it.
|
|
85
|
+
_PERMISSION_NOTE = (
|
|
86
|
+
"enumeration permission and content permission are granted separately, so a "
|
|
87
|
+
"successful listing is not evidence that bytes will be served"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# The credential half of each match is replaced, the naming half kept, so a
|
|
91
|
+
# redacted line still says which key was masked.
|
|
92
|
+
_BEARER = re.compile(r"(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}")
|
|
93
|
+
_KEYS = re.compile(
|
|
94
|
+
r"([\"']?(?:access_token|refresh_token|id_token|device_code)[\"']?\s*[:=]\s*[\"']?)"
|
|
95
|
+
r"[A-Za-z0-9._~+/=-]{8,}"
|
|
96
|
+
)
|
|
97
|
+
_URL = re.compile(r"https?://[^\s\"'<>]+")
|
|
98
|
+
_MASK = "***"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RestartFromZeroError(SharefetchError):
|
|
102
|
+
"""The requested range will not be served, so the file restarts at offset zero.
|
|
103
|
+
|
|
104
|
+
Raised for the two classification rows that mean the same to the caller:
|
|
105
|
+
a ``200`` answering a request that carried a ``Range`` header, which is a
|
|
106
|
+
server that ignored the range, and a ``416``, which is a recorded offset
|
|
107
|
+
past the current length. The response is discarded before the raise, so the
|
|
108
|
+
caller reopens its ``.part`` file with ``wb`` and requests again without a
|
|
109
|
+
range.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, message: str, status: int) -> None:
|
|
113
|
+
super().__init__(message, status=status)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def safe_url(url: str) -> str:
|
|
117
|
+
"""Return ``url`` with the query and fragment dropped unless the host is Graph.
|
|
118
|
+
|
|
119
|
+
The pre-authenticated storage URL carries a credential in its query string,
|
|
120
|
+
so no non-Graph URL is ever logged whole. A Graph URL keeps its query,
|
|
121
|
+
because ``$select`` and ``$top`` are addressing rather than credentials.
|
|
122
|
+
"""
|
|
123
|
+
parts = urlsplit(url)
|
|
124
|
+
if not parts.query and not parts.fragment:
|
|
125
|
+
return url
|
|
126
|
+
if (parts.hostname or "").lower() == GRAPH_HOST:
|
|
127
|
+
return url
|
|
128
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def redact(text: str) -> str:
|
|
132
|
+
"""Mask every credential in ``text`` and strip every non-Graph query string."""
|
|
133
|
+
masked = _BEARER.sub(rf"\1{_MASK}", text)
|
|
134
|
+
masked = _KEYS.sub(rf"\1{_MASK}", masked)
|
|
135
|
+
return _URL.sub(lambda match: safe_url(match.group(0)), masked)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class RedactingFilter(logging.Filter):
|
|
139
|
+
"""Masks credentials and non-Graph query strings on every record that passes.
|
|
140
|
+
|
|
141
|
+
Attached by the front ends to every handler they install, so a log line
|
|
142
|
+
written by this package, by ``requests`` or by ``urllib3`` is covered alike.
|
|
143
|
+
The record is rewritten in place, which is what makes the mask survive the
|
|
144
|
+
handler's own formatting.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
148
|
+
if isinstance(record.msg, str):
|
|
149
|
+
record.msg = redact(record.msg)
|
|
150
|
+
args = record.args
|
|
151
|
+
if isinstance(args, dict):
|
|
152
|
+
record.args = {
|
|
153
|
+
key: redact(value) if isinstance(value, str) else value
|
|
154
|
+
for key, value in args.items()
|
|
155
|
+
}
|
|
156
|
+
elif isinstance(args, tuple):
|
|
157
|
+
record.args = tuple(redact(item) if isinstance(item, str) else item for item in args)
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class GraphClient:
|
|
162
|
+
"""Performs every HTTP request the run makes.
|
|
163
|
+
|
|
164
|
+
The client is shared by enumeration and by every transfer worker and is safe
|
|
165
|
+
to call from all of them: the session is thread-safe for distinct requests,
|
|
166
|
+
the pacer carries its own lock, and the client holds no per-request state on
|
|
167
|
+
the instance.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
tokens: TokenProvider,
|
|
173
|
+
pacer: Pacer,
|
|
174
|
+
connections: int,
|
|
175
|
+
sink: EventSink,
|
|
176
|
+
*,
|
|
177
|
+
session: Any | None = None,
|
|
178
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Build the client and, unless one is supplied, its pooled session.
|
|
181
|
+
|
|
182
|
+
``connections`` sizes the connection pool to the concurrency limit and
|
|
183
|
+
is validated here, so a caller that skipped ``config.check_connections``
|
|
184
|
+
still cannot open a third connection. ``session`` and ``sleep`` exist so
|
|
185
|
+
a test can drive the classification table without a socket and without
|
|
186
|
+
elapsing real time; a supplied session is used as given and is never
|
|
187
|
+
re-mounted.
|
|
188
|
+
"""
|
|
189
|
+
self._tokens = tokens
|
|
190
|
+
self._pacer = pacer
|
|
191
|
+
self._sink = sink
|
|
192
|
+
self._sleep = sleep
|
|
193
|
+
self._connections = check_connections(connections)
|
|
194
|
+
if session is None:
|
|
195
|
+
session = requests.Session()
|
|
196
|
+
adapter = HTTPAdapter(
|
|
197
|
+
pool_connections=self._connections,
|
|
198
|
+
pool_maxsize=self._connections,
|
|
199
|
+
)
|
|
200
|
+
session.mount("https://", adapter)
|
|
201
|
+
session.mount("http://", adapter)
|
|
202
|
+
self._session = session
|
|
203
|
+
self._closed = False
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def connections(self) -> int:
|
|
207
|
+
"""The validated concurrency limit the connection pool is sized to."""
|
|
208
|
+
return self._connections
|
|
209
|
+
|
|
210
|
+
def get_json(self, url: str) -> dict[str, Any]:
|
|
211
|
+
"""Return the JSON object at ``url``, having classified the response.
|
|
212
|
+
|
|
213
|
+
Raises :class:`~sharefetch.errors.EnumerationError` where the body is
|
|
214
|
+
not a JSON object, and the errors :meth:`stream` raises for every other
|
|
215
|
+
classification row. Each of them carries the HTTP status on ``.status``,
|
|
216
|
+
so a caller probing candidates can tell a ``404`` from a ``403``.
|
|
217
|
+
"""
|
|
218
|
+
response = self._perform(url, None, stream=False)
|
|
219
|
+
status = response.status_code
|
|
220
|
+
try:
|
|
221
|
+
payload = response.json()
|
|
222
|
+
except ValueError as exc:
|
|
223
|
+
raise EnumerationError(
|
|
224
|
+
f"{safe_url(url)} answered {status} with a body that is not JSON",
|
|
225
|
+
status=status,
|
|
226
|
+
) from exc
|
|
227
|
+
finally:
|
|
228
|
+
response.close()
|
|
229
|
+
if not isinstance(payload, dict):
|
|
230
|
+
raise EnumerationError(
|
|
231
|
+
f"{safe_url(url)} answered with a JSON {type(payload).__name__} "
|
|
232
|
+
"where an object was expected",
|
|
233
|
+
status=status,
|
|
234
|
+
)
|
|
235
|
+
return payload
|
|
236
|
+
|
|
237
|
+
def stream(
|
|
238
|
+
self,
|
|
239
|
+
url: str,
|
|
240
|
+
headers: dict[str, str] | None = None,
|
|
241
|
+
*,
|
|
242
|
+
authenticated: bool = True,
|
|
243
|
+
) -> requests.Response:
|
|
244
|
+
"""Return an open streaming response for ``url``, which the caller closes.
|
|
245
|
+
|
|
246
|
+
``headers`` carries the ``Range`` header when resuming. The
|
|
247
|
+
``Authorization`` header is added here and never by the caller, so a
|
|
248
|
+
redirect crossing hosts drops it.
|
|
249
|
+
|
|
250
|
+
``authenticated=False`` withholds the bearer token for the whole
|
|
251
|
+
request, including every redirect it follows. It is used for the
|
|
252
|
+
``@microsoft.graph.downloadUrl`` fallback: that URL
|
|
253
|
+
addresses a pre-authenticated storage host, carries its own credential
|
|
254
|
+
in its query string, and can refuse a bearer token outright. Everything
|
|
255
|
+
else is unchanged, so the fallback is paced, retried, classified and
|
|
256
|
+
redacted exactly as a Graph request is.
|
|
257
|
+
|
|
258
|
+
Raises :class:`RestartFromZeroError` where the range will not be served,
|
|
259
|
+
:class:`~sharefetch.errors.AuthError` on a second ``401``, and
|
|
260
|
+
:class:`~sharefetch.errors.TransferError` where the item is refused or
|
|
261
|
+
the retry ceiling is exhausted. An unauthenticated request has no token
|
|
262
|
+
to refresh, so its ``401`` is terminal for the item on the first one.
|
|
263
|
+
"""
|
|
264
|
+
return self._perform(url, headers, stream=True, authenticated=authenticated)
|
|
265
|
+
|
|
266
|
+
def close(self) -> None:
|
|
267
|
+
"""Close the underlying session. Calling it twice is harmless."""
|
|
268
|
+
if self._closed:
|
|
269
|
+
return
|
|
270
|
+
self._closed = True
|
|
271
|
+
self._session.close()
|
|
272
|
+
logger.debug("graph client session closed")
|
|
273
|
+
|
|
274
|
+
def __enter__(self) -> GraphClient:
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
def __exit__(self, *_exc: object) -> None:
|
|
278
|
+
self.close()
|
|
279
|
+
|
|
280
|
+
def _perform(
|
|
281
|
+
self,
|
|
282
|
+
url: str,
|
|
283
|
+
headers: dict[str, str] | None,
|
|
284
|
+
stream: bool,
|
|
285
|
+
authenticated: bool = True,
|
|
286
|
+
) -> requests.Response:
|
|
287
|
+
"""Run one logical request to completion through the classification table.
|
|
288
|
+
|
|
289
|
+
Attempts are bounded by ``config.MAX_ATTEMPTS_PER_FILE``. A ``401``
|
|
290
|
+
carries its own bound of one retry, independent of that ceiling, because
|
|
291
|
+
a second ``401`` is terminal for the run rather than retryable.
|
|
292
|
+
|
|
293
|
+
``authenticated`` is the only thing the download-URL fallback changes.
|
|
294
|
+
With it false no token is requested and no ``Authorization`` header is
|
|
295
|
+
built, so nothing in this method holds one to send.
|
|
296
|
+
"""
|
|
297
|
+
request_headers = dict(headers or {})
|
|
298
|
+
ranged = any(name.lower() == "range" for name in request_headers)
|
|
299
|
+
shown = safe_url(url)
|
|
300
|
+
unauthorized = 0
|
|
301
|
+
last_failure = "no attempt completed"
|
|
302
|
+
last_status: int | None = None
|
|
303
|
+
|
|
304
|
+
for attempt in range(1, MAX_ATTEMPTS_PER_FILE + 1):
|
|
305
|
+
self._pacer.acquire()
|
|
306
|
+
sent = dict(request_headers)
|
|
307
|
+
if authenticated:
|
|
308
|
+
sent["Authorization"] = f"Bearer {self._tokens.get_token()}"
|
|
309
|
+
try:
|
|
310
|
+
response = self._session.request(
|
|
311
|
+
"GET",
|
|
312
|
+
url,
|
|
313
|
+
headers=sent,
|
|
314
|
+
timeout=REQUEST_TIMEOUT,
|
|
315
|
+
stream=stream,
|
|
316
|
+
allow_redirects=True,
|
|
317
|
+
)
|
|
318
|
+
except _TRANSPORT_FAILURES as exc:
|
|
319
|
+
last_failure = redact(f"{type(exc).__name__}: {exc}")
|
|
320
|
+
seconds = backoff_delay(attempt)
|
|
321
|
+
logger.warning(
|
|
322
|
+
"attempt %d of %d for %s failed at the transport layer with %s, "
|
|
323
|
+
"backing off %.3fs",
|
|
324
|
+
attempt,
|
|
325
|
+
MAX_ATTEMPTS_PER_FILE,
|
|
326
|
+
shown,
|
|
327
|
+
type(exc).__name__,
|
|
328
|
+
seconds,
|
|
329
|
+
)
|
|
330
|
+
self._wait(seconds)
|
|
331
|
+
continue
|
|
332
|
+
except requests.RequestException as exc:
|
|
333
|
+
# Everything left over, such as TooManyRedirects or a malformed
|
|
334
|
+
# URL, is a fault a retry cannot fix.
|
|
335
|
+
raise TransferError(
|
|
336
|
+
f"{shown} could not be requested: {redact(f'{type(exc).__name__}: {exc}')}"
|
|
337
|
+
) from exc
|
|
338
|
+
|
|
339
|
+
status = response.status_code
|
|
340
|
+
last_status = status
|
|
341
|
+
|
|
342
|
+
if status in _STATUS_SUCCESS:
|
|
343
|
+
if status == _STATUS_OK and ranged:
|
|
344
|
+
response.close()
|
|
345
|
+
raise RestartFromZeroError(
|
|
346
|
+
f"{shown} answered 200 to a request carrying a Range header, "
|
|
347
|
+
"so the server ignored the range and the file restarts at zero",
|
|
348
|
+
status,
|
|
349
|
+
)
|
|
350
|
+
logger.debug("%s answered %d on attempt %d", shown, status, attempt)
|
|
351
|
+
return response
|
|
352
|
+
|
|
353
|
+
if status == _STATUS_UNAUTHORIZED:
|
|
354
|
+
response.close()
|
|
355
|
+
if not authenticated:
|
|
356
|
+
# No token was sent, so there is none to refresh. The URL
|
|
357
|
+
# carries its own credential and the host rejected it.
|
|
358
|
+
raise TransferError(
|
|
359
|
+
f"{shown} answered 401 to a request carrying no bearer token, so the "
|
|
360
|
+
"credential the URL carries was refused and no refresh can change it",
|
|
361
|
+
status=status,
|
|
362
|
+
)
|
|
363
|
+
unauthorized += 1
|
|
364
|
+
if unauthorized > 1:
|
|
365
|
+
raise AuthError(
|
|
366
|
+
f"{shown} answered 401 again after a forced token refresh, "
|
|
367
|
+
"which is terminal for the run",
|
|
368
|
+
status=status,
|
|
369
|
+
)
|
|
370
|
+
logger.warning("%s answered 401, forcing one token refresh and retrying", shown)
|
|
371
|
+
self._tokens.get_token(force_refresh=True)
|
|
372
|
+
last_failure = "HTTP 401"
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
if status in _STATUS_THROTTLED:
|
|
376
|
+
seconds = self._retry_wait(response, attempt)
|
|
377
|
+
response.close()
|
|
378
|
+
reason = f"HTTP {status}"
|
|
379
|
+
last_failure = reason
|
|
380
|
+
logger.warning(
|
|
381
|
+
"%s answered %d, holding every worker for %.3fs", shown, status, seconds
|
|
382
|
+
)
|
|
383
|
+
self._sink.emit(Throttled(seconds=seconds, reason=reason))
|
|
384
|
+
self._pacer.hold(seconds)
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
if status in _STATUS_SERVER_RETRYABLE:
|
|
388
|
+
seconds = self._retry_wait(response, attempt)
|
|
389
|
+
response.close()
|
|
390
|
+
last_failure = f"HTTP {status}"
|
|
391
|
+
logger.warning(
|
|
392
|
+
"%s answered %d, retrying in %.3fs (attempt %d of %d)",
|
|
393
|
+
shown,
|
|
394
|
+
status,
|
|
395
|
+
seconds,
|
|
396
|
+
attempt,
|
|
397
|
+
MAX_ATTEMPTS_PER_FILE,
|
|
398
|
+
)
|
|
399
|
+
self._wait(seconds)
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
if status == _STATUS_RANGE_NOT_SATISFIABLE:
|
|
403
|
+
excerpt = self._excerpt(response)
|
|
404
|
+
raise RestartFromZeroError(
|
|
405
|
+
f"{shown} answered 416, so the recorded offset is past the current "
|
|
406
|
+
f"length and the file restarts at zero: {excerpt}",
|
|
407
|
+
status,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if status == _STATUS_FORBIDDEN:
|
|
411
|
+
excerpt = self._excerpt(response)
|
|
412
|
+
raise TransferError(
|
|
413
|
+
f"{shown} answered 403, which is terminal for this item. "
|
|
414
|
+
f"{_PERMISSION_NOTE}. Body: {excerpt}",
|
|
415
|
+
status=status,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
if status == _STATUS_NOT_FOUND:
|
|
419
|
+
excerpt = self._excerpt(response)
|
|
420
|
+
raise TransferError(
|
|
421
|
+
f"{shown} answered 404, so the item vanished since enumeration. "
|
|
422
|
+
f"Body: {excerpt}",
|
|
423
|
+
status=status,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
excerpt = self._excerpt(response)
|
|
427
|
+
raise TransferError(
|
|
428
|
+
f"{shown} answered {status}, which is terminal for this item. Body: {excerpt}",
|
|
429
|
+
status=status,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
raise TransferError(
|
|
433
|
+
f"{shown} did not succeed in {MAX_ATTEMPTS_PER_FILE} attempts, "
|
|
434
|
+
f"the last failure was {last_failure}",
|
|
435
|
+
status=last_status,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def _retry_wait(self, response: Any, attempt: int) -> float:
|
|
439
|
+
"""Return the wait for a retryable status, from ``Retry-After`` or backoff.
|
|
440
|
+
|
|
441
|
+
``parse_retry_after`` returns ``None`` where the header is missing or
|
|
442
|
+
unusable, which is the fall-through to the backoff curve and never a
|
|
443
|
+
wait of zero.
|
|
444
|
+
"""
|
|
445
|
+
honoured = parse_retry_after(response.headers.get("Retry-After"))
|
|
446
|
+
if honoured is not None:
|
|
447
|
+
return honoured
|
|
448
|
+
return backoff_delay(attempt)
|
|
449
|
+
|
|
450
|
+
def _wait(self, seconds: float) -> None:
|
|
451
|
+
"""Sleep for ``seconds`` where it is positive."""
|
|
452
|
+
if seconds > 0:
|
|
453
|
+
self._sleep(seconds)
|
|
454
|
+
|
|
455
|
+
def _excerpt(self, response: Any) -> str:
|
|
456
|
+
"""Return a bounded, whitespace-collapsed excerpt of a refused response body.
|
|
457
|
+
|
|
458
|
+
The response is closed before returning, because the caller raises on
|
|
459
|
+
every path that reaches here.
|
|
460
|
+
"""
|
|
461
|
+
raw = b""
|
|
462
|
+
try:
|
|
463
|
+
for piece in response.iter_content(_BODY_EXCERPT_BYTES):
|
|
464
|
+
raw = piece or b""
|
|
465
|
+
break
|
|
466
|
+
except (requests.RequestException, OSError, ValueError) as exc:
|
|
467
|
+
logger.debug("the refused response body could not be read: %s", type(exc).__name__)
|
|
468
|
+
finally:
|
|
469
|
+
response.close()
|
|
470
|
+
if not raw:
|
|
471
|
+
return "(empty)"
|
|
472
|
+
encoding = getattr(response, "encoding", None) or "utf-8"
|
|
473
|
+
text = raw.decode(encoding, errors="replace")
|
|
474
|
+
return redact(" ".join(text.split()))[:_BODY_EXCERPT_CHARS]
|