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 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
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m sharefetch``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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()