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/hashes.py ADDED
@@ -0,0 +1,123 @@
1
+ """quickXorHash, the content hash Microsoft Graph reports for a drive item.
2
+
3
+ quickXorHash is a 160-bit shift-and-xor checksum published by Microsoft and
4
+ returned in a drive item's ``file.hashes`` facet. Recomputing it over the bytes
5
+ written to disk is the only content check that works on every file type without
6
+ any knowledge of the format, so it is verification check 2 and the strongest
7
+ evidence the tool has that a transfer delivered the declared bytes.
8
+
9
+ The algorithm keeps a 160-bit register held in three cells, of which the last
10
+ carries 32 usable bits rather than 64. Each input byte is exclusive-ored into
11
+ the register at a bit position that advances by 11 bits per byte and wraps at
12
+ 160, so bytes 160 apart share a position and fold together. The digest is the
13
+ register written little-endian into 20 bytes, with the total input length
14
+ exclusive-ored in as a little-endian 64-bit value over the final 8 bytes.
15
+
16
+ The module is pure: it opens no socket and reads no clock.
17
+ """
18
+
19
+ import base64
20
+ import logging
21
+ from functools import reduce
22
+ from operator import xor
23
+ from pathlib import Path
24
+
25
+ from .errors import ValidationError
26
+ from .paths import long_path
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _WIDTH_IN_BITS = 160
31
+ _SHIFT = 11
32
+ _CELL_BITS = 64
33
+ # The last cell holds 160 - 128 bits. A byte landing near its top wraps round to
34
+ # the first cell, which is what makes the register 160 bits wide and not 192.
35
+ _BITS_IN_LAST_CELL = _WIDTH_IN_BITS - _CELL_BITS * (_WIDTH_IN_BITS // _CELL_BITS)
36
+ _CELL_COUNT = (_WIDTH_IN_BITS - 1) // _CELL_BITS + 1
37
+ _BYTE_MASK = 0xFF
38
+ _BITS_PER_BYTE = 8
39
+
40
+ DIGEST_SIZE = 20
41
+ LENGTH_FIELD_BYTES = 8
42
+
43
+
44
+ class QuickXorHash:
45
+ """Incremental quickXorHash over a byte stream.
46
+
47
+ Updating in chunks produces the same digest as updating once with the
48
+ concatenation, at every chunk width, because ``_shifted`` carries the bit
49
+ position of the next byte across the call boundary.
50
+ """
51
+
52
+ def __init__(self) -> None:
53
+ self._data = [0] * _CELL_COUNT
54
+ self._length = 0
55
+ self._shifted = 0
56
+
57
+ def update(self, data: bytes) -> None:
58
+ """Fold ``data`` into the register and advance the bit position."""
59
+ if not data:
60
+ return
61
+ shift = self._shifted
62
+ last_cell = _CELL_COUNT - 1
63
+ for i in range(min(len(data), _WIDTH_IN_BITS)):
64
+ # Every byte whose index is congruent to i modulo the register width
65
+ # lands on the same bit position, so they fold before they are
66
+ # written. Exclusive or is associative, so the result is identical.
67
+ folded = reduce(xor, data[i::_WIDTH_IN_BITS], 0)
68
+ if folded == 0:
69
+ continue
70
+ cell, offset = divmod((shift + _SHIFT * i) % _WIDTH_IN_BITS, _CELL_BITS)
71
+ capacity = _BITS_IN_LAST_CELL if cell == last_cell else _CELL_BITS
72
+ self._data[cell] ^= (folded << offset) & ((1 << capacity) - 1)
73
+ if offset > capacity - _BITS_PER_BYTE:
74
+ # The byte straddles the end of this cell. The bits masked off
75
+ # above continue at the bottom of the next one, wrapping from
76
+ # the last cell back to the first.
77
+ spill_cell = 0 if cell == last_cell else cell + 1
78
+ self._data[spill_cell] ^= folded >> (capacity - offset)
79
+ self._shifted = (shift + _SHIFT * len(data)) % _WIDTH_IN_BITS
80
+ self._length += len(data)
81
+
82
+ def digest(self) -> bytes:
83
+ """Return the 20-byte digest, length field included."""
84
+ out = bytearray(DIGEST_SIZE)
85
+ for i in range(DIGEST_SIZE):
86
+ cell, offset = divmod(i * _BITS_PER_BYTE, _CELL_BITS)
87
+ out[i] = (self._data[cell] >> offset) & _BYTE_MASK
88
+ for i in range(LENGTH_FIELD_BYTES):
89
+ out[DIGEST_SIZE - LENGTH_FIELD_BYTES + i] ^= (
90
+ self._length >> (_BITS_PER_BYTE * i)
91
+ ) & _BYTE_MASK
92
+ return bytes(out)
93
+
94
+ def b64(self) -> str:
95
+ """Return the digest as Graph reports it in ``file.hashes.quickXorHash``."""
96
+ return base64.b64encode(self.digest()).decode("ascii")
97
+
98
+
99
+ def quick_xor_hash_file(path: Path, chunk_size: int) -> str:
100
+ """Return the base64 quickXorHash of the file at ``path``.
101
+
102
+ ``chunk_size`` bounds the memory the read holds at once and changes nothing
103
+ about the result. The file is opened through ``paths.long_path`` so a
104
+ destination deeper than 260 characters is readable on Windows.
105
+ """
106
+ if chunk_size <= 0:
107
+ raise ValidationError(
108
+ f"chunk size must be a positive number of bytes, received {chunk_size}"
109
+ )
110
+ digest = QuickXorHash()
111
+ total = 0
112
+ # long_path returns an extended-length path string, which Path would
113
+ # normalise away, so the builtin open is the correct call here.
114
+ with open(long_path(path), "rb") as handle: # noqa: PTH123
115
+ while True:
116
+ block = handle.read(chunk_size)
117
+ if not block:
118
+ break
119
+ digest.update(block)
120
+ total += len(block)
121
+ value = digest.b64()
122
+ logger.debug("hashed %s: %d bytes, quickXorHash %s", path, total, value)
123
+ return value
sharefetch/listing.py ADDED
@@ -0,0 +1,493 @@
1
+ """Depth-first enumeration of a drive folder tree, one page at a time.
2
+
3
+ :func:`walk` yields one :class:`RemoteItem` per file beneath the addressed
4
+ folder, in the order Graph returns it, so a resumed run revisits items in a
5
+ stable sequence. A folder contributes no item of its own; it is recursed into.
6
+
7
+ Four properties hold here and are the reason the module exists.
8
+
9
+ * The walk is lazy. Each page is fetched only when the consumer has drained the
10
+ items the previous page produced, so a large tree never has to be held in
11
+ memory and a consumer that stops early stops the requests too.
12
+ * Every request goes through :class:`~sharefetch.graph.GraphClient`, which
13
+ carries the pacer, the retry ceiling and the response classification table.
14
+ This module adds no retry loop of its own.
15
+ * A refusal of one container is recorded and the walk continues: a per-item
16
+ failure never ends the run. A refusal of the addressed
17
+ folder itself is fatal, because the run has nothing left to do.
18
+ * ``@microsoft.graph.downloadUrl`` is never named in ``$select``. It is an
19
+ instance annotation, and naming it makes Graph omit it silently. Where Graph
20
+ volunteers it, it is carried on the item as ``download_url``, which is used
21
+ only as a fallback when the content endpoint fails.
22
+
23
+ No extension allowlist exists anywhere in this package. Every enumerated file is
24
+ yielded whatever its name, its extension, or the absence of one. The only
25
+ filtering is the caller's ``include`` and ``exclude`` globs.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import fnmatch
31
+ import logging
32
+ from collections.abc import Callable, Iterator
33
+ from dataclasses import dataclass
34
+ from typing import TYPE_CHECKING, Any
35
+ from urllib.parse import quote, urlsplit
36
+
37
+ from .config import GRAPH_HOST, GRAPH_ROOT, ITEM_SELECT, PAGE_SIZE, check_max_depth
38
+ from .errors import EnumerationError, TransferError, ValidationError
39
+ from .events import EnumerationComplete, EnumerationProgress
40
+ from .graph import GraphClient, safe_url
41
+
42
+ if TYPE_CHECKING: # pragma: no cover - resolve is imported for its type only
43
+ from .resolve import DriveTarget
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ __all__ = [
48
+ "ProgressCallback",
49
+ "RemoteItem",
50
+ "SkippedItem",
51
+ "WalkEvent",
52
+ "walk",
53
+ ]
54
+
55
+ # The two facets that make an item a container. A package, such as a OneNote
56
+ # notebook, enumerates as a folder and is recursed into, so its sections
57
+ # transfer as individual files.
58
+ _FOLDER_FACET = "folder"
59
+ _PACKAGE_FACET = "package"
60
+ _FILE_FACET = "file"
61
+
62
+ _NEXT_LINK = "@odata.nextLink"
63
+
64
+ # The instance annotation the transfer falls back to. It is never named in
65
+ # ``$select``; it is read off the item where Graph volunteered it.
66
+ _DOWNLOAD_URL = "@microsoft.graph.downloadUrl"
67
+
68
+ # The annotation points at a pre-authenticated storage host whose name varies by
69
+ # tenant and by region, so no host allowlist can be stated here. The scheme can:
70
+ # the credential travels in the query string, and an ``http`` URL would put it
71
+ # on the wire in clear. A value that is not absolute ``https`` is dropped, and
72
+ # the item transfers from the content endpoint alone.
73
+ _DOWNLOAD_URL_SCHEME = "https"
74
+
75
+ # The depth of the addressed folder itself. A file directly inside it carries
76
+ # this depth, a file one folder down carries this depth plus one, and
77
+ # ``max_depth`` is compared against the depth of the folder being entered.
78
+ _ROOT_DEPTH = 0
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class RemoteItem:
83
+ """One file on the drive, addressed by its drive-relative path.
84
+
85
+ ``drive_path`` is the identity used by the state file, the local path
86
+ mapping and the manifest: drive-relative, forward slashes, no leading
87
+ slash. ``depth`` counts folder levels beneath the addressed folder, so a
88
+ file directly inside it is depth 0.
89
+
90
+ ``download_url`` is the ``@microsoft.graph.downloadUrl`` annotation where
91
+ Graph volunteered one and it is an absolute ``https`` URL, and ``None``
92
+ otherwise. It is a fallback and is never the first URL tried. It carries a
93
+ credential in its query string, so it is never logged whole:
94
+ :func:`sharefetch.graph.safe_url` is applied at every log site.
95
+ """
96
+
97
+ drive_path: str
98
+ item_id: str
99
+ name: str
100
+ size: int
101
+ etag: str | None
102
+ ctag: str | None
103
+ mime_type: str | None
104
+ quick_xor_hash: str | None
105
+ depth: int
106
+ download_url: str | None = None
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class SkippedItem:
111
+ """An item the walk stepped over, reported so the run can record it skipped.
112
+
113
+ Two things reach here: a container the server refuses to enumerate, whose
114
+ ``reason`` is the server's own refusal, already redacted by
115
+ :mod:`sharefetch.graph`; and an item carrying no file, folder or package
116
+ facet, which has neither bytes to fetch nor children to list.
117
+ ``is_package`` marks a package refusing enumeration, held apart from an
118
+ ordinary folder the account may list but not read.
119
+ """
120
+
121
+ drive_path: str
122
+ reason: str
123
+ is_package: bool
124
+
125
+
126
+ WalkEvent = EnumerationProgress | EnumerationComplete | SkippedItem
127
+ ProgressCallback = Callable[[WalkEvent], None]
128
+
129
+
130
+ @dataclass
131
+ class _WalkState:
132
+ """The running counts and the caller's filters, shared by every recursion level."""
133
+
134
+ include: tuple[str, ...]
135
+ exclude: tuple[str, ...]
136
+ max_depth: int | None
137
+ on_progress: ProgressCallback | None
138
+ folders: int = 0
139
+ files: int = 0
140
+ total_bytes: int = 0
141
+
142
+ def report(self, event: WalkEvent) -> None:
143
+ """Hand one event to the caller's callback, where one was supplied.
144
+
145
+ An exception raised by the callback propagates and ends the walk, which
146
+ is how a front end cancels an enumeration in progress.
147
+ """
148
+ if self.on_progress is not None:
149
+ self.on_progress(event)
150
+
151
+ def progress(self) -> EnumerationProgress:
152
+ return EnumerationProgress(
153
+ folders=self.folders,
154
+ files=self.files,
155
+ total_bytes=self.total_bytes,
156
+ )
157
+
158
+ def complete(self) -> EnumerationComplete:
159
+ return EnumerationComplete(
160
+ files=self.files,
161
+ total_bytes=self.total_bytes,
162
+ folders=self.folders,
163
+ )
164
+
165
+
166
+ def walk(
167
+ target: DriveTarget,
168
+ client: GraphClient,
169
+ *,
170
+ include: tuple[str, ...] = (),
171
+ exclude: tuple[str, ...] = (),
172
+ max_depth: int | None = None,
173
+ on_progress: ProgressCallback | None = None,
174
+ ) -> Iterator[RemoteItem]:
175
+ """Yield every file beneath ``target``, depth-first, in Graph's own order.
176
+
177
+ ``include`` globs are applied first: with any present, a file matching none
178
+ of them is dropped. ``exclude`` globs are applied second and remove matches.
179
+ Both match the drive-relative path with :func:`fnmatch.fnmatch`. Neither is
180
+ applied to a folder, so ``--include '*.pdf'`` still recurses the whole tree.
181
+
182
+ ``max_depth`` stops recursion below N levels beneath the addressed folder,
183
+ and ``0`` means the addressed folder only.
184
+
185
+ ``on_progress`` receives an :class:`~sharefetch.events.EnumerationProgress`
186
+ after each page, a :class:`SkippedItem` for each container the server
187
+ refuses, and one :class:`~sharefetch.events.EnumerationComplete` when the
188
+ walk finishes. Counts are cumulative and count what the walk yields, so the
189
+ final progress and the completion event agree.
190
+
191
+ Raises :class:`~sharefetch.errors.ValidationError` for an unusable target or
192
+ depth limit, and :class:`~sharefetch.errors.EnumerationError` where the
193
+ addressed folder is refused or a listing is malformed.
194
+ """
195
+ drive_id = getattr(target, "drive_id", "")
196
+ if not isinstance(drive_id, str) or not drive_id.strip():
197
+ raise ValidationError("the drive target carries no drive id, so nothing can be listed")
198
+ folder_path = _normalise_folder(getattr(target, "folder_path", ""))
199
+ state = _WalkState(
200
+ include=tuple(include),
201
+ exclude=tuple(exclude),
202
+ max_depth=check_max_depth(max_depth),
203
+ on_progress=on_progress,
204
+ )
205
+ return _walk(client, drive_id, folder_path, state)
206
+
207
+
208
+ def _walk(
209
+ client: GraphClient,
210
+ drive_id: str,
211
+ folder_path: str,
212
+ state: _WalkState,
213
+ ) -> Iterator[RemoteItem]:
214
+ """Drive the recursion and announce completion once the tree is exhausted."""
215
+ logger.info(
216
+ "enumerating %s beneath %s",
217
+ _shown(folder_path),
218
+ drive_id,
219
+ )
220
+ yield from _walk_folder(
221
+ client,
222
+ drive_id,
223
+ folder_path,
224
+ _ROOT_DEPTH,
225
+ state,
226
+ is_package=False,
227
+ is_root=True,
228
+ )
229
+ logger.info(
230
+ "enumeration complete: %d files, %d folders, %d bytes",
231
+ state.files,
232
+ state.folders,
233
+ state.total_bytes,
234
+ )
235
+ state.report(state.complete())
236
+
237
+
238
+ def _walk_folder(
239
+ client: GraphClient,
240
+ drive_id: str,
241
+ folder_path: str,
242
+ depth: int,
243
+ state: _WalkState,
244
+ *,
245
+ is_package: bool,
246
+ is_root: bool,
247
+ ) -> Iterator[RemoteItem]:
248
+ """Yield the files in one container and recurse into its own containers.
249
+
250
+ Paging terminates when a page carries no ``@odata.nextLink``. A link that
251
+ has already been followed, or one addressing a host other than Graph, raises
252
+ :class:`~sharefetch.errors.EnumerationError`: the first is a server loop
253
+ that would repeat items indefinitely, the second would carry the bearer
254
+ token off Graph.
255
+ """
256
+ url: str | None = _children_url(drive_id, folder_path)
257
+ seen: set[str] = {url}
258
+ while url is not None:
259
+ try:
260
+ payload = client.get_json(url)
261
+ except TransferError as exc:
262
+ reason = str(exc)
263
+ if is_root:
264
+ raise EnumerationError(
265
+ f"the addressed folder {_shown(folder_path)} could not be listed: {reason}"
266
+ ) from exc
267
+ logger.warning(
268
+ "skipping %s %s, which the server refused: %s",
269
+ "package" if is_package else "folder",
270
+ _shown(folder_path),
271
+ reason,
272
+ )
273
+ state.report(
274
+ SkippedItem(
275
+ drive_path=folder_path,
276
+ reason=reason,
277
+ is_package=is_package,
278
+ )
279
+ )
280
+ return
281
+
282
+ children = payload.get("value")
283
+ if not isinstance(children, list):
284
+ raise EnumerationError(
285
+ f"the listing of {_shown(folder_path)} carries no value array, "
286
+ f"which means the response is not a children collection"
287
+ )
288
+
289
+ for child in children:
290
+ yield from _walk_child(client, drive_id, folder_path, depth, state, child)
291
+
292
+ state.report(state.progress())
293
+ url = _next_link(payload, folder_path, seen)
294
+
295
+
296
+ def _walk_child(
297
+ client: GraphClient,
298
+ drive_id: str,
299
+ folder_path: str,
300
+ depth: int,
301
+ state: _WalkState,
302
+ child: Any,
303
+ ) -> Iterator[RemoteItem]:
304
+ """Yield one child where it is a file, or recurse into it where it is a container."""
305
+ if not isinstance(child, dict):
306
+ raise EnumerationError(
307
+ f"the listing of {_shown(folder_path)} carries a "
308
+ f"{type(child).__name__} where an item object was expected"
309
+ )
310
+ name = child.get("name")
311
+ if not isinstance(name, str) or not name:
312
+ raise EnumerationError(f"an item in the listing of {_shown(folder_path)} carries no name")
313
+ child_path = f"{folder_path}/{name}" if folder_path else name
314
+
315
+ is_package = _PACKAGE_FACET in child
316
+ if _FOLDER_FACET in child or is_package:
317
+ state.folders += 1
318
+ child_depth = depth + 1
319
+ if state.max_depth is not None and child_depth > state.max_depth:
320
+ logger.debug(
321
+ "not recursing into %s: depth %d is past the limit of %d",
322
+ child_path,
323
+ child_depth,
324
+ state.max_depth,
325
+ )
326
+ return
327
+ yield from _walk_folder(
328
+ client,
329
+ drive_id,
330
+ child_path,
331
+ child_depth,
332
+ state,
333
+ is_package=is_package,
334
+ is_root=False,
335
+ )
336
+ return
337
+
338
+ if _FILE_FACET not in child:
339
+ # A shortcut or a remote item carries neither facet, so there are no
340
+ # bytes to fetch and no children to list. It is reported and stepped
341
+ # over rather than guessed at.
342
+ reason = "the item carries neither a file, a folder nor a package facet"
343
+ logger.warning("skipping %s: %s", child_path, reason)
344
+ state.report(SkippedItem(drive_path=child_path, reason=reason, is_package=False))
345
+ return
346
+
347
+ if not _selected(child_path, state.include, state.exclude):
348
+ logger.debug("filtered out %s", child_path)
349
+ return
350
+
351
+ item = _to_item(child, child_path, depth, folder_path)
352
+ state.files += 1
353
+ state.total_bytes += item.size
354
+ yield item
355
+
356
+
357
+ def _to_item(child: dict[str, Any], drive_path: str, depth: int, folder_path: str) -> RemoteItem:
358
+ """Build a :class:`RemoteItem` from one Graph driveItem object."""
359
+ item_id = child.get("id")
360
+ if not isinstance(item_id, str) or not item_id:
361
+ raise EnumerationError(
362
+ f"the item {drive_path} in the listing of {_shown(folder_path)} carries no id"
363
+ )
364
+ file_facet = child.get(_FILE_FACET)
365
+ file_facet = file_facet if isinstance(file_facet, dict) else {}
366
+ hashes = file_facet.get("hashes")
367
+ hashes = hashes if isinstance(hashes, dict) else {}
368
+ return RemoteItem(
369
+ drive_path=drive_path,
370
+ item_id=item_id,
371
+ name=str(child["name"]),
372
+ size=_size_of(child, drive_path),
373
+ etag=_text(child.get("eTag")),
374
+ ctag=_text(child.get("cTag")),
375
+ mime_type=_text(file_facet.get("mimeType")),
376
+ quick_xor_hash=_text(hashes.get("quickXorHash")),
377
+ depth=depth,
378
+ download_url=_download_url(child.get(_DOWNLOAD_URL), drive_path),
379
+ )
380
+
381
+
382
+ def _download_url(value: Any, drive_path: str) -> str | None:
383
+ """Return the fallback download URL, or ``None`` where it is unusable.
384
+
385
+ The value is server-supplied and is fetched without the bearer token, so
386
+ the guard the next link needs, that the host is Graph, does not transfer:
387
+ the token cannot leak to a host this URL names. What remains is a URL whose
388
+ credential lives in its query string, so the scheme is required to be
389
+ ``https``. A value that is absent, not a string, not absolute, or on another
390
+ scheme is dropped and the item transfers from the content endpoint alone.
391
+
392
+ Nothing about the rejected value is logged beyond its scheme, because the
393
+ query string is the credential.
394
+ """
395
+ if value is None:
396
+ return None
397
+ if not isinstance(value, str) or not value:
398
+ logger.warning(
399
+ "%s carries a download URL annotation that is a %s, which is not usable as a URL",
400
+ drive_path,
401
+ type(value).__name__,
402
+ )
403
+ return None
404
+ parts = urlsplit(value)
405
+ if parts.scheme.lower() != _DOWNLOAD_URL_SCHEME or not parts.hostname:
406
+ logger.warning(
407
+ "%s carries a download URL on scheme %r, and only %s is accepted, so the "
408
+ "fallback is dropped",
409
+ drive_path,
410
+ parts.scheme,
411
+ _DOWNLOAD_URL_SCHEME,
412
+ )
413
+ return None
414
+ return value
415
+
416
+
417
+ def _size_of(child: dict[str, Any], drive_path: str) -> int:
418
+ """Return the item's size, treating an absent or unusable value as zero.
419
+
420
+ Graph omits ``size`` on some items. A zero-length file is legitimate, and
421
+ verification compares the bytes written against the recorded size, so a
422
+ wrong size is caught there rather than guessed at here.
423
+ """
424
+ size = child.get("size")
425
+ if isinstance(size, bool) or not isinstance(size, int) or size < 0:
426
+ if size is not None:
427
+ logger.warning("item %s reported an unusable size %r, recording zero", drive_path, size)
428
+ return 0
429
+ return size
430
+
431
+
432
+ def _text(value: Any) -> str | None:
433
+ """Return ``value`` where it is a non-empty string, otherwise ``None``."""
434
+ if isinstance(value, str) and value:
435
+ return value
436
+ return None
437
+
438
+
439
+ def _selected(drive_path: str, include: tuple[str, ...], exclude: tuple[str, ...]) -> bool:
440
+ """Apply the include globs, then the exclude globs, to a drive-relative path."""
441
+ if include and not any(fnmatch.fnmatch(drive_path, pattern) for pattern in include):
442
+ return False
443
+ return not any(fnmatch.fnmatch(drive_path, pattern) for pattern in exclude)
444
+
445
+
446
+ def _next_link(payload: dict[str, Any], folder_path: str, seen: set[str]) -> str | None:
447
+ """Return the next page's URL, or ``None`` where the listing is exhausted."""
448
+ link = payload.get(_NEXT_LINK)
449
+ if link is None:
450
+ return None
451
+ if not isinstance(link, str) or not link:
452
+ raise EnumerationError(
453
+ f"the listing of {_shown(folder_path)} carries a "
454
+ f"{type(link).__name__} where a next link was expected"
455
+ )
456
+ host = (urlsplit(link).hostname or "").lower()
457
+ if host != GRAPH_HOST:
458
+ raise EnumerationError(
459
+ f"the listing of {_shown(folder_path)} points its next page at "
460
+ f"{host or 'no host'}, which is not {GRAPH_HOST}"
461
+ )
462
+ if link in seen:
463
+ raise EnumerationError(
464
+ f"the listing of {_shown(folder_path)} points its next page at "
465
+ f"{safe_url(link)}, a page already fetched, so the paging does not terminate"
466
+ )
467
+ seen.add(link)
468
+ return link
469
+
470
+
471
+ def _children_url(drive_id: str, folder_path: str) -> str:
472
+ """Address a folder's children by path."""
473
+ query = f"?$select={ITEM_SELECT}&$top={PAGE_SIZE}"
474
+ base = f"{GRAPH_ROOT}/drives/{quote(drive_id, safe='')}"
475
+ if not folder_path:
476
+ return f"{base}/root/children{query}"
477
+ return f"{base}/root:/{quote(folder_path, safe='/')}:/children{query}"
478
+
479
+
480
+ def _normalise_folder(folder_path: Any) -> str:
481
+ """Return the addressed folder as a drive-relative path with no stray separators."""
482
+ if folder_path is None:
483
+ return ""
484
+ if not isinstance(folder_path, str):
485
+ raise ValidationError(
486
+ f"the folder path must be a string, received {type(folder_path).__name__}"
487
+ )
488
+ return "/".join(segment for segment in folder_path.replace("\\", "/").split("/") if segment)
489
+
490
+
491
+ def _shown(folder_path: str) -> str:
492
+ """Name a folder for a log line or a message, including the drive root."""
493
+ return folder_path or "the drive root"