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