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/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]