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/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Resumable, rate-limited downloads from Microsoft Graph drives.
|
|
2
|
+
|
|
3
|
+
The package mirrors a SharePoint or OneDrive drive to local storage over the
|
|
4
|
+
Microsoft Graph API. Transfers resume from a state file after an interruption,
|
|
5
|
+
requests are throttled to stay inside the service limits, and no download path
|
|
6
|
+
removes user data: a failed or partial transfer leaves its file on disk.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.0"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
sharefetch/__main__.py
ADDED
sharefetch/auth.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Device-code sign-in and access-token supply for one run.
|
|
2
|
+
|
|
3
|
+
:class:`TokenProvider` owns the msal public client application and is the only
|
|
4
|
+
place in the package that holds an access token. ``GraphClient`` calls
|
|
5
|
+
:meth:`TokenProvider.get_token` before every request, so the token this module
|
|
6
|
+
returns is the single credential the run uses.
|
|
7
|
+
|
|
8
|
+
Three properties hold here and are the reason the module exists:
|
|
9
|
+
|
|
10
|
+
* One refresh at a time. ``get_token`` takes a re-entrant lock and re-checks the
|
|
11
|
+
cached token after taking it, so two workers reaching expiry together produce
|
|
12
|
+
one refresh and one sign-in.
|
|
13
|
+
* No token in a log. No logging call in this module receives a flow dict, a
|
|
14
|
+
result dict, an access token, a refresh token or a device code. Account
|
|
15
|
+
identifiers and exception class names are logged, and nothing else.
|
|
16
|
+
* Responsive cancellation. :meth:`TokenProvider.cancel` sets the event that
|
|
17
|
+
msal's polling loop tests once per second, so a cancelled sign-in stops
|
|
18
|
+
without waiting for the device code to expire.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from typing import Any, Protocol
|
|
28
|
+
|
|
29
|
+
import msal
|
|
30
|
+
import requests
|
|
31
|
+
|
|
32
|
+
from .config import AUTHORITY_TEMPLATE, MSAL_SCOPES
|
|
33
|
+
from .errors import AuthError
|
|
34
|
+
from .events import DeviceCodeCompleted, DeviceCodeRequested, EventSink
|
|
35
|
+
from .tokencache import build_cache
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# msal treats an access token with less than five minutes of life left as
|
|
40
|
+
# expired and refreshes it (application.py:1762). The copy cached here goes
|
|
41
|
+
# stale at the same point, so this layer never hands out a token msal itself
|
|
42
|
+
# would decline to reuse. The value mirrors a library fact rather than tuning a
|
|
43
|
+
# bound, so it stays beside the one calculation that reads it.
|
|
44
|
+
_EXPIRY_MARGIN_SECONDS = 5 * 60
|
|
45
|
+
|
|
46
|
+
# Exceptions msal lets through from the network layer, the token cache and a
|
|
47
|
+
# malformed identity-provider response. A silent refresh that raises one of
|
|
48
|
+
# these falls back to the device flow; an interactive acquisition that raises
|
|
49
|
+
# one is terminal for the run.
|
|
50
|
+
_MSAL_FAILURES = (ValueError, KeyError, OSError, requests.RequestException)
|
|
51
|
+
|
|
52
|
+
_UNKNOWN_ACCOUNT = "an unidentified account"
|
|
53
|
+
_CANCELLED = "the sign-in was cancelled"
|
|
54
|
+
_EXPIRED = "the device code expired before it was entered"
|
|
55
|
+
_REFUSED = "the identity provider refused the sign-in"
|
|
56
|
+
_REQUEST_REFUSED = "the device authorization request was refused"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MsalApplication(Protocol):
|
|
60
|
+
"""The part of ``msal.PublicClientApplication`` this module uses.
|
|
61
|
+
|
|
62
|
+
Naming it keeps the dependency on msal to four calls and lets a test supply
|
|
63
|
+
an application that completes a flow without a network or a real sign-in.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def get_accounts(self) -> list[dict[str, Any]]: ...
|
|
67
|
+
|
|
68
|
+
def acquire_token_silent(
|
|
69
|
+
self, scopes: list[str], account: dict[str, Any] | None = None
|
|
70
|
+
) -> dict[str, Any] | None: ...
|
|
71
|
+
|
|
72
|
+
def initiate_device_flow(self, scopes: list[str] | None = None) -> dict[str, Any]: ...
|
|
73
|
+
|
|
74
|
+
def acquire_token_by_device_flow(
|
|
75
|
+
self, flow: dict[str, Any], **kwargs: Any
|
|
76
|
+
) -> dict[str, Any]: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _as_float(value: object, default: float) -> float:
|
|
80
|
+
"""Return ``value`` as a float, or ``default`` where it is not numeric."""
|
|
81
|
+
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
|
82
|
+
return default
|
|
83
|
+
try:
|
|
84
|
+
return float(value)
|
|
85
|
+
except ValueError:
|
|
86
|
+
return default
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _description(payload: object) -> str:
|
|
90
|
+
"""Return the identity provider's own account of a refusal, verbatim.
|
|
91
|
+
|
|
92
|
+
Returns the empty string where the payload carries neither key, so a caller
|
|
93
|
+
can state its own reason without appending an invented one.
|
|
94
|
+
"""
|
|
95
|
+
if not isinstance(payload, dict):
|
|
96
|
+
return ""
|
|
97
|
+
for key in ("error_description", "error"):
|
|
98
|
+
value = payload.get(key)
|
|
99
|
+
if isinstance(value, str) and value.strip():
|
|
100
|
+
return value
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _refusal(reason: str, payload: object) -> str:
|
|
105
|
+
"""Compose an error message carrying the server's description unaltered."""
|
|
106
|
+
description = _description(payload)
|
|
107
|
+
return f"{reason}: {description}" if description else reason
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _account_label(payload: object) -> str:
|
|
111
|
+
"""Return a human-readable identifier for the signed-in account.
|
|
112
|
+
|
|
113
|
+
Accepts either an account entry from the cache or a token result. Carries no
|
|
114
|
+
credential: the keys read are an identifier, never token material.
|
|
115
|
+
"""
|
|
116
|
+
if isinstance(payload, dict):
|
|
117
|
+
for key in ("username", "home_account_id"):
|
|
118
|
+
value = payload.get(key)
|
|
119
|
+
if isinstance(value, str) and value.strip():
|
|
120
|
+
return value
|
|
121
|
+
claims = payload.get("id_token_claims")
|
|
122
|
+
if isinstance(claims, dict):
|
|
123
|
+
for key in ("preferred_username", "upn", "sub"):
|
|
124
|
+
value = claims.get(key)
|
|
125
|
+
if isinstance(value, str) and value.strip():
|
|
126
|
+
return value
|
|
127
|
+
return _UNKNOWN_ACCOUNT
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _access_token(result: object) -> str | None:
|
|
131
|
+
"""Return the access token in ``result``, or ``None`` where there is none."""
|
|
132
|
+
if not isinstance(result, dict):
|
|
133
|
+
return None
|
|
134
|
+
token = result.get("access_token")
|
|
135
|
+
if isinstance(token, str) and token:
|
|
136
|
+
return token
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TokenProvider:
|
|
141
|
+
"""Supplies the access token for every Graph request made by one run.
|
|
142
|
+
|
|
143
|
+
The provider is shared by every worker thread and is safe to call from all
|
|
144
|
+
of them. ``client_id`` and ``tenant`` are validated by ``config`` before
|
|
145
|
+
they arrive here.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
client_id: str,
|
|
151
|
+
tenant: str,
|
|
152
|
+
sink: EventSink,
|
|
153
|
+
reauth: bool = False,
|
|
154
|
+
*,
|
|
155
|
+
app: MsalApplication | None = None,
|
|
156
|
+
clock: Callable[[], float] = time.time,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Build the provider and its msal application.
|
|
159
|
+
|
|
160
|
+
``reauth`` skips the cached account once, so the first acquisition of the
|
|
161
|
+
run is interactive. ``app`` and ``clock`` exist so a test can drive the
|
|
162
|
+
flow without a network, a real sign-in or a real wait.
|
|
163
|
+
"""
|
|
164
|
+
self._sink = sink
|
|
165
|
+
self._reauth = reauth
|
|
166
|
+
self._clock = clock
|
|
167
|
+
self._lock = threading.RLock()
|
|
168
|
+
self._cancelled = threading.Event()
|
|
169
|
+
# One tuple, so a reader outside the lock never observes a token paired
|
|
170
|
+
# with the expiry of a different token.
|
|
171
|
+
self._cached: tuple[str, float] | None = None
|
|
172
|
+
self._generation = 0
|
|
173
|
+
self._app: MsalApplication = (
|
|
174
|
+
app
|
|
175
|
+
if app is not None
|
|
176
|
+
else msal.PublicClientApplication(
|
|
177
|
+
client_id,
|
|
178
|
+
authority=AUTHORITY_TEMPLATE.format(tenant=tenant),
|
|
179
|
+
token_cache=build_cache(),
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def get_token(self, force_refresh: bool = False) -> str:
|
|
184
|
+
"""Return an access token, refreshing or signing in where required.
|
|
185
|
+
|
|
186
|
+
``force_refresh`` discards the cached token, which is how a ``401``
|
|
187
|
+
mid-transfer is answered. A forced refresh that arrives while another
|
|
188
|
+
worker is already refreshing returns that worker's new token instead of
|
|
189
|
+
starting a second refresh.
|
|
190
|
+
|
|
191
|
+
Raises :class:`~sharefetch.errors.AuthError` where the sign-in is
|
|
192
|
+
refused, expires or is cancelled.
|
|
193
|
+
"""
|
|
194
|
+
generation = self._generation
|
|
195
|
+
if not force_refresh:
|
|
196
|
+
cached = self._cached_token()
|
|
197
|
+
if cached is not None:
|
|
198
|
+
return cached
|
|
199
|
+
with self._lock:
|
|
200
|
+
refreshed_meanwhile = self._generation != generation
|
|
201
|
+
if refreshed_meanwhile or not force_refresh:
|
|
202
|
+
cached = self._cached_token()
|
|
203
|
+
if cached is not None:
|
|
204
|
+
return cached
|
|
205
|
+
return self._acquire()
|
|
206
|
+
|
|
207
|
+
def cancel(self) -> None:
|
|
208
|
+
"""Stop a device-code poll in progress.
|
|
209
|
+
|
|
210
|
+
msal tests the exit condition once per second, so a poll stops within
|
|
211
|
+
about a second of this call. A provider that has been cancelled refuses
|
|
212
|
+
every later interactive acquisition.
|
|
213
|
+
"""
|
|
214
|
+
self._cancelled.set()
|
|
215
|
+
logger.info("sign-in cancellation requested")
|
|
216
|
+
|
|
217
|
+
def _cached_token(self) -> str | None:
|
|
218
|
+
"""Return the cached access token while it remains usable."""
|
|
219
|
+
cached = self._cached
|
|
220
|
+
if cached is None:
|
|
221
|
+
return None
|
|
222
|
+
token, expires_at = cached
|
|
223
|
+
if self._clock() < expires_at:
|
|
224
|
+
return token
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def _acquire(self) -> str:
|
|
228
|
+
"""Acquire a token silently where possible and interactively otherwise.
|
|
229
|
+
|
|
230
|
+
Called with the lock held. Drops the cached token first, so a failure
|
|
231
|
+
never leaves a stale token behind for the next caller to hand out.
|
|
232
|
+
"""
|
|
233
|
+
self._cached = None
|
|
234
|
+
result = None if self._reauth else self._acquire_silent()
|
|
235
|
+
if result is None:
|
|
236
|
+
result = self._acquire_by_device_code()
|
|
237
|
+
token = _access_token(result)
|
|
238
|
+
if token is None:
|
|
239
|
+
raise AuthError(_refusal(_REFUSED, result))
|
|
240
|
+
lifetime = _as_float(result.get("expires_in"), 0.0)
|
|
241
|
+
self._cached = (token, self._clock() + max(0.0, lifetime - _EXPIRY_MARGIN_SECONDS))
|
|
242
|
+
self._generation += 1
|
|
243
|
+
return token
|
|
244
|
+
|
|
245
|
+
def _acquire_silent(self) -> dict[str, Any] | None:
|
|
246
|
+
"""Refresh from the token cache, or return ``None`` to sign in again.
|
|
247
|
+
|
|
248
|
+
Every failure here is recoverable: the device flow follows and costs the
|
|
249
|
+
user one code entry.
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
accounts = self._app.get_accounts()
|
|
253
|
+
except _MSAL_FAILURES as exc:
|
|
254
|
+
logger.warning(
|
|
255
|
+
"the token cache could not be read (%s); a device code sign-in follows",
|
|
256
|
+
type(exc).__name__,
|
|
257
|
+
)
|
|
258
|
+
return None
|
|
259
|
+
if not accounts:
|
|
260
|
+
logger.debug("no account in the token cache; a device code sign-in follows")
|
|
261
|
+
return None
|
|
262
|
+
account = accounts[0]
|
|
263
|
+
label = _account_label(account)
|
|
264
|
+
try:
|
|
265
|
+
result = self._app.acquire_token_silent(list(MSAL_SCOPES), account=account)
|
|
266
|
+
except _MSAL_FAILURES as exc:
|
|
267
|
+
logger.warning(
|
|
268
|
+
"the silent refresh for %s failed (%s); a device code sign-in follows",
|
|
269
|
+
label,
|
|
270
|
+
type(exc).__name__,
|
|
271
|
+
)
|
|
272
|
+
return None
|
|
273
|
+
if _access_token(result) is None:
|
|
274
|
+
logger.info(
|
|
275
|
+
"the cached grant for %s no longer yields a token; a device code sign-in follows",
|
|
276
|
+
label,
|
|
277
|
+
)
|
|
278
|
+
return None
|
|
279
|
+
logger.debug("refreshed the access token for %s without interaction", label)
|
|
280
|
+
return result
|
|
281
|
+
|
|
282
|
+
def _acquire_by_device_code(self) -> dict[str, Any]:
|
|
283
|
+
"""Run the device authorization grant to completion.
|
|
284
|
+
|
|
285
|
+
Emits :class:`~sharefetch.events.DeviceCodeRequested` once the code is
|
|
286
|
+
available and :class:`~sharefetch.events.DeviceCodeCompleted` once the
|
|
287
|
+
user has entered it. Raises :class:`~sharefetch.errors.AuthError` on a
|
|
288
|
+
refusal, an expiry or a cancellation.
|
|
289
|
+
"""
|
|
290
|
+
if self._cancelled.is_set():
|
|
291
|
+
raise AuthError(_CANCELLED)
|
|
292
|
+
flow = self._initiate()
|
|
293
|
+
self._sink.emit(
|
|
294
|
+
DeviceCodeRequested(
|
|
295
|
+
user_code=str(flow["user_code"]),
|
|
296
|
+
verification_uri=str(flow.get("verification_uri", "")),
|
|
297
|
+
message=str(flow.get("message", "")),
|
|
298
|
+
expires_at=_as_float(flow.get("expires_at"), 0.0),
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
logger.info("a device code sign-in is required; the front end is displaying the code")
|
|
302
|
+
try:
|
|
303
|
+
result = self._app.acquire_token_by_device_flow(
|
|
304
|
+
flow, exit_condition=self._exit_condition
|
|
305
|
+
)
|
|
306
|
+
except _MSAL_FAILURES as exc:
|
|
307
|
+
raise AuthError(f"{_REFUSED}: {type(exc).__name__}") from exc
|
|
308
|
+
if _access_token(result) is None:
|
|
309
|
+
raise AuthError(self._failure_message(flow, result))
|
|
310
|
+
label = _account_label(result)
|
|
311
|
+
logger.info("the device code sign-in completed for %s", label)
|
|
312
|
+
self._sink.emit(DeviceCodeCompleted(account=label))
|
|
313
|
+
# --reauth applies to the first acquisition only, so a refresh later in
|
|
314
|
+
# the run does not demand a second code.
|
|
315
|
+
self._reauth = False
|
|
316
|
+
return result
|
|
317
|
+
|
|
318
|
+
def _initiate(self) -> dict[str, Any]:
|
|
319
|
+
"""Request a device code, or raise where the request is refused."""
|
|
320
|
+
try:
|
|
321
|
+
flow = self._app.initiate_device_flow(scopes=list(MSAL_SCOPES))
|
|
322
|
+
except _MSAL_FAILURES as exc:
|
|
323
|
+
raise AuthError(f"{_REQUEST_REFUSED}: {type(exc).__name__}") from exc
|
|
324
|
+
if not isinstance(flow, dict) or not flow.get("user_code"):
|
|
325
|
+
raise AuthError(_refusal(_REQUEST_REFUSED, flow))
|
|
326
|
+
return flow
|
|
327
|
+
|
|
328
|
+
def _failure_message(self, flow: dict[str, Any], result: object) -> str:
|
|
329
|
+
"""Name why the device flow ended without a token.
|
|
330
|
+
|
|
331
|
+
A cancelled or expired poll returns msal's last pending response, whose
|
|
332
|
+
description describes the poll and not the outcome, so the reason is
|
|
333
|
+
stated here and the server's description is appended unaltered.
|
|
334
|
+
"""
|
|
335
|
+
if self._cancelled.is_set():
|
|
336
|
+
return _CANCELLED
|
|
337
|
+
if _as_float(flow.get("expires_at"), 0.0) < self._clock():
|
|
338
|
+
return _refusal(_EXPIRED, result)
|
|
339
|
+
return _refusal(_REFUSED, result)
|
|
340
|
+
|
|
341
|
+
def _exit_condition(self, flow: dict[str, Any]) -> bool:
|
|
342
|
+
"""Tell msal's polling loop to stop. Tested once per second."""
|
|
343
|
+
return self._cancelled.is_set() or _as_float(flow.get("expires_at"), 0.0) < self._clock()
|