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/engine.py ADDED
@@ -0,0 +1,1055 @@
1
+ """Orchestration: one URL, one destination, one verified tree and one manifest.
2
+
3
+ This module joins every other module in the package and is the only thing a
4
+ front end calls. The seam is one function and one protocol: :func:`run` takes a
5
+ :class:`~sharefetch.config.Settings` and an
6
+ :class:`~sharefetch.events.EventSink`, emits the run's events and returns a
7
+ :class:`~sharefetch.events.RunSummary`. Nothing here writes to a
8
+ console and nothing here holds a reference to Tk.
9
+
10
+ The run has eight stages and each one emits its own event.
11
+
12
+ 1. Parse the URL, which is pure and needs no network.
13
+ 2. Build the one :class:`~sharefetch.pacing.Pacer`, the one
14
+ :class:`~sharefetch.auth.TokenProvider` and the one
15
+ :class:`~sharefetch.graph.GraphClient` the whole run shares. A second pacer
16
+ would double the effective request rate at two connections, so exactly one
17
+ is constructed and it is handed to both the client and every transfer.
18
+ 3. Resolve the reference to a drive and a folder.
19
+ 4. Enumerate the tree, mapping each drive-relative path onto a local path. An
20
+ addressed folder that answers ``404`` here is re-resolved once, one
21
+ percent-decode pass short of the ceiling the URL consumed, under four
22
+ conditions.
23
+ 5. Reconcile the enumeration against what is already on disk.
24
+ 6. Check free space against what the queue still has to fetch.
25
+ 7. Transfer, on a pool of one or two workers.
26
+ 8. Verify the whole tree, apply the hash implementation safeguard, and write
27
+ the manifest.
28
+
29
+ ``--dry-run`` stops after stage 5 and transfers nothing, so the operator sees
30
+ what would be fetched and the state file records it. ``--verify-only`` runs
31
+ stage 8's tree pass alone: it builds no token provider and no client, so the
32
+ run makes no network call at all and needs no sign-in.
33
+
34
+ The engine thread owns the queue and the pool. A worker owns one file and
35
+ reaches the state file only through :class:`~sharefetch.state.StateStore`,
36
+ which serialises its own writes.
37
+ ``pause`` and ``stop`` are :class:`threading.Event` objects created here and
38
+ passed into every :func:`sharefetch.transfer.fetch` call, so a front end that
39
+ sets one reaches a transfer already in flight at its next chunk boundary.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ import os
46
+ import shutil
47
+ import threading
48
+ import time
49
+ from collections import deque
50
+ from collections.abc import Callable
51
+ from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
52
+ from pathlib import Path
53
+ from typing import Any
54
+
55
+ from .auth import TokenProvider
56
+ from .config import (
57
+ CHECK_CONTENT_HASH,
58
+ CHECK_NOT_SUBSTITUTED,
59
+ FREE_SPACE_MARGIN,
60
+ REASON_HASH_MISMATCH,
61
+ REASON_PATH_TOO_LONG,
62
+ STATE_FILENAME,
63
+ STATUS_COMPLETE,
64
+ STATUS_FAILED,
65
+ STATUS_SKIPPED,
66
+ VERIFICATION_FAILED,
67
+ Settings,
68
+ )
69
+ from .errors import (
70
+ AuthError,
71
+ EnumerationError,
72
+ ManifestError,
73
+ SharefetchError,
74
+ ValidationError,
75
+ VerificationError,
76
+ )
77
+ from .events import (
78
+ EnumerationComplete,
79
+ EventSink,
80
+ FileFinished,
81
+ Reconciled,
82
+ Resolved,
83
+ RunFinished,
84
+ RunSummary,
85
+ )
86
+ from .graph import GraphClient
87
+ from .listing import RemoteItem, SkippedItem, walk
88
+ from .manifest import write_manifest
89
+ from .pacing import Pacer
90
+ from .paths import long_path, map_drive_path
91
+ from .resolve import DriveTarget, decode_pass_ceiling
92
+ from .resolve import resolve as resolve_reference
93
+ from .state import ItemState, StateStore
94
+ from .transfer import STATUS_PAUSED, fetch
95
+ from .urls import SitePathRef, SourceRef
96
+ from .urls import parse as parse_url
97
+ from .verify import VerificationResult, verify_file
98
+
99
+ logger = logging.getLogger(__name__)
100
+
101
+ # How often a paused engine wakes to test whether it has been resumed or
102
+ # stopped. The interval lives beside the only loop that reads it. It bounds the
103
+ # delay between a front end calling resume() and the queue moving again, and
104
+ # nothing else.
105
+ PAUSE_POLL_SECONDS = 0.1
106
+
107
+ BYTES_PER_MIB = 1024 * 1024
108
+
109
+ # A worker thread name prefix, so a stack dump taken from a hung run names the
110
+ # pool the frame belongs to rather than an anonymous "ThreadPoolExecutor-0_1".
111
+ WORKER_NAME_PREFIX = "sharefetch-transfer"
112
+
113
+ # The one HTTP status the decode-pass fallback answers. A 403 names a folder
114
+ # that exists and the account may not read, and
115
+ # re-resolving a different path on that evidence would report the wrong problem
116
+ # and spend a site probe and a drive list doing it.
117
+ NOT_FOUND = 404
118
+
119
+
120
+ def _exists(path: Path) -> bool:
121
+ """Whether ``path`` names an existing file, read through the long-path form.
122
+
123
+ Every filesystem call in the package goes through ``paths.long_path``, so a
124
+ destination deeper than 260 characters behaves the same as a shallow one.
125
+ """
126
+ try:
127
+ return os.path.isfile(long_path(path)) # noqa: PTH113
128
+ except OSError as exc:
129
+ logger.warning("could not test whether %s exists, treating it as absent: %s", path, exc)
130
+ return False
131
+
132
+
133
+ class _EnumerationCancelledError(Exception):
134
+ """Raised inside the walk's progress callback to end enumeration early.
135
+
136
+ ``listing.walk`` is a lazy generator and an exception raised inside
137
+ ``on_progress`` propagates out of it, which is the only way to stop a walk
138
+ that is part way through paging a large folder. The exception never leaves
139
+ this module: :meth:`Engine._enumerate` catches it and the run ends without
140
+ touching the state file, so a stop during enumeration leaves the previous
141
+ run's records exactly as they were.
142
+ """
143
+
144
+
145
+ def run(settings: Settings, sink: EventSink, **injected: Any) -> RunSummary:
146
+ """Execute one run and return its summary.
147
+
148
+ The convenience both front ends call. ``injected`` is forwarded to
149
+ :class:`Engine`, which is how a test supplies a client, a resolver, a
150
+ walker, a clock or a sleep without a socket.
151
+ """
152
+ return Engine(settings, sink, **injected).run()
153
+
154
+
155
+ class Engine:
156
+ """One run: resolve, enumerate, reconcile, transfer, verify, report.
157
+
158
+ An instance drives a single run and is not reused. :meth:`pause`,
159
+ :meth:`resume` and :meth:`stop` are the only methods safe to call from
160
+ another thread, which is what a graphical front end does from its main
161
+ thread while :meth:`run` executes on a worker.
162
+ """
163
+
164
+ def __init__(
165
+ self,
166
+ settings: Settings,
167
+ sink: EventSink,
168
+ *,
169
+ tokens: Any | None = None,
170
+ client: GraphClient | None = None,
171
+ resolver: Callable[..., DriveTarget] = resolve_reference,
172
+ walker: Callable[..., Any] = walk,
173
+ fetcher: Callable[..., str] = fetch,
174
+ verifier: Callable[..., VerificationResult] = verify_file,
175
+ manifest_writer: Callable[[StateStore, Path], Path] = write_manifest,
176
+ disk_usage: Callable[[str], Any] = shutil.disk_usage,
177
+ sleep: Callable[[float], None] = time.sleep,
178
+ ) -> None:
179
+ """Build the engine and its two control events.
180
+
181
+ Every collaborator after ``sink`` has the production default and exists
182
+ so a test can drive the orchestration without a socket, without a
183
+ sign-in and without elapsing real time. ``tokens`` and ``client`` are
184
+ built inside :meth:`run` when they are not supplied, because
185
+ ``--verify-only`` must build neither.
186
+ """
187
+ self._settings = settings
188
+ self._sink = sink
189
+ self._dest = Path(settings.dest)
190
+ self._resolver = resolver
191
+ self._walker = walker
192
+ self._fetch = fetcher
193
+ self._verify = verifier
194
+ self._write_manifest = manifest_writer
195
+ self._disk_usage = disk_usage
196
+ self._sleep = sleep
197
+
198
+ self._tokens = tokens
199
+ self._client = client
200
+ self._pacer: Pacer | None = None
201
+ self._store: StateStore | None = None
202
+ self._drive_id: str | None = None
203
+ self._remote: dict[str, RemoteItem] = {}
204
+
205
+ self._pause = threading.Event()
206
+ self._stop = threading.Event()
207
+
208
+ # Whether the walk produced any output before it failed, and whether
209
+ # the decode-pass fallback has been spent. Only
210
+ # the fallback reads either, and the second bounds it to one retry.
211
+ self._enumeration_produced = False
212
+ self._decode_fallback_spent = False
213
+
214
+ # Run-local tallies. The remaining counts are read off the state store
215
+ # at summary time, so they cannot disagree with the item statuses.
216
+ self._transferred = 0
217
+ self._bytes_transferred = 0
218
+ self._enumeration_skipped = 0
219
+ self._unmappable = 0
220
+ self._hash_comparisons = 0
221
+ self._hash_matches = 0
222
+ self._hash_only_failures = 0
223
+
224
+ # ------------------------------------------------------------------
225
+ # Control, called from another thread
226
+ # ------------------------------------------------------------------
227
+
228
+ def pause(self) -> None:
229
+ """Suspend the queue at the next chunk boundary and flush state."""
230
+ self._pause.set()
231
+ logger.info("pause requested")
232
+
233
+ def resume(self) -> None:
234
+ """Release a pause. A paused file continues by range on its next pass."""
235
+ self._pause.clear()
236
+ logger.info("resume requested")
237
+
238
+ def stop(self) -> None:
239
+ """Finish the files in flight, stop queueing new work and end the run.
240
+
241
+ A stop clears any pause, because a paused engine would otherwise sit in
242
+ its wait loop rather than draining and returning.
243
+ """
244
+ self._stop.set()
245
+ self._pause.clear()
246
+ logger.info("stop requested")
247
+
248
+ # ------------------------------------------------------------------
249
+ # The run
250
+ # ------------------------------------------------------------------
251
+
252
+ def run(self) -> RunSummary:
253
+ """Execute the run and return its summary.
254
+
255
+ Raises every fatal error class unchanged:
256
+ ``ValidationError`` for a bad URL, a bad destination or a free-space
257
+ shortfall, ``AuthError`` for a refused sign-in, ``ResolveError`` for a
258
+ site, library or folder that does not resolve, ``EnumerationError`` for
259
+ a listing that cannot be read, and ``StateError`` for a state file that
260
+ cannot be written. A per-item ``TransferError`` or ``VerificationError``
261
+ is recorded against its item by the transfer layer and the queue
262
+ continues, so neither ever reaches this method.
263
+ """
264
+ self._make_destination()
265
+ if self._settings.verify_only:
266
+ return self._run_verify_only()
267
+ return self._run_transfer()
268
+
269
+ def _run_verify_only(self) -> RunSummary:
270
+ """Run the verification tree pass alone, with no network and no token."""
271
+ logger.info("verify-only run over %s, no network call is made", self._dest)
272
+ store = StateStore.load(self._dest)
273
+ if store is None:
274
+ raise ValidationError(
275
+ f"--verify-only needs the state file {self._dest / STATE_FILENAME} to know each "
276
+ f"file's declared size and hash, and no readable state file is present; run "
277
+ f"without --verify-only first"
278
+ )
279
+ self._store = store
280
+ self._verify_tree()
281
+ self._apply_hash_safeguard()
282
+ store.flush(force=True)
283
+ return self._finish(self._write_the_manifest())
284
+
285
+ def _run_transfer(self) -> RunSummary:
286
+ """Run the full sequence: resolve, enumerate, reconcile, transfer, verify."""
287
+ reference = parse_url(self._settings.url)
288
+ self._open_connection()
289
+ target = self._resolver(reference, self._client)
290
+ self._announce(target)
291
+
292
+ store = StateStore.load(self._dest) or StateStore(self._dest)
293
+ self._store = store
294
+
295
+ target, items = self._enumerate_addressed_folder(reference, target)
296
+ if items is None:
297
+ logger.info("the run was stopped during enumeration, the state file is untouched")
298
+ return self._stopped_during_enumeration_summary()
299
+
300
+ # Set from the target that was actually listed, which the decode-pass
301
+ # fallback can replace with a second resolution.
302
+ self._drive_id = target.drive_id
303
+ store.replace_enumeration(self._enumeration_header(target), items)
304
+ counts = store.reconcile(restart=self._settings.restart)
305
+ self._sink.emit(Reconciled(counts=dict(counts)))
306
+
307
+ if self._settings.dry_run:
308
+ logger.info("dry run: %d item(s) reconciled and no content transferred", len(items))
309
+ store.flush(force=True)
310
+ return self._finish(None)
311
+
312
+ self._check_free_space(store.remaining_bytes())
313
+ self._drain_queue()
314
+ if not self._stop.is_set():
315
+ self._verify_tree()
316
+ self._apply_hash_safeguard()
317
+ store.flush(force=True)
318
+ return self._finish(self._write_the_manifest())
319
+
320
+ # ------------------------------------------------------------------
321
+ # Setup
322
+ # ------------------------------------------------------------------
323
+
324
+ def _make_destination(self) -> None:
325
+ """Create the destination directory, which every later stage writes into."""
326
+ try:
327
+ os.makedirs(long_path(self._dest), exist_ok=True) # noqa: PTH103
328
+ except OSError as exc:
329
+ raise ValidationError(
330
+ f"the destination directory {self._dest} could not be created: {exc}"
331
+ ) from exc
332
+
333
+ def _announce(self, target: DriveTarget) -> None:
334
+ """Emit ``Resolved`` for the target the run is about to enumerate.
335
+
336
+ Called a second time where the decode-pass fallback re-resolved, so the
337
+ folder a front end shows is the one that was actually listed. Both
338
+ front ends replace what they hold: the command line writes a second log
339
+ line and the window resets its source label.
340
+ """
341
+ self._sink.emit(
342
+ Resolved(
343
+ site_web_url=target.site_web_url,
344
+ drive_name=target.drive_name,
345
+ folder_path=target.folder_path,
346
+ drive_id=target.drive_id,
347
+ )
348
+ )
349
+ logger.info(
350
+ "resolved %s to the %s library, folder %r",
351
+ self._settings.url,
352
+ target.drive_name,
353
+ target.folder_path,
354
+ )
355
+
356
+ def _open_connection(self) -> None:
357
+ """Build the one pacer, token provider and client the run shares.
358
+
359
+ Exactly one :class:`~sharefetch.pacing.Pacer` exists per run and it is
360
+ handed to both the client and every transfer. A second one would let
361
+ two workers each spend the configured inter-request delay independently
362
+ and double the rate the service sees.
363
+ """
364
+ settings = self._settings
365
+ self._pacer = Pacer(settings.delay, settings.limit_rate_kbps)
366
+ if self._client is not None:
367
+ return
368
+ if self._tokens is None:
369
+ self._tokens = TokenProvider(
370
+ settings.client_id,
371
+ settings.tenant,
372
+ self._sink,
373
+ reauth=settings.reauth,
374
+ )
375
+ self._client = GraphClient(
376
+ self._tokens,
377
+ self._pacer,
378
+ settings.connections,
379
+ self._sink,
380
+ )
381
+
382
+ # ------------------------------------------------------------------
383
+ # Enumeration
384
+ # ------------------------------------------------------------------
385
+
386
+ def _enumerate_addressed_folder(
387
+ self, reference: SourceRef, target: DriveTarget
388
+ ) -> tuple[DriveTarget, dict[str, ItemState] | None]:
389
+ """Enumerate the tree, retrying once one decode pass short where 4.5 allows.
390
+
391
+ Returns the target that was listed and its items, with ``None`` items
392
+ where a stop ended the walk.
393
+
394
+ Normalisation rule 5 decodes the ``id`` parameter until the value stops
395
+ changing, which is right for the doubly encoded value a Teams link
396
+ carries and wrong for a folder genuinely named ``A%20B``: that arrives
397
+ as ``A%2520B`` and stabilises at ``A B``. A literal percent sign and an
398
+ extra encoding pass are indistinguishable by inspection, so the service
399
+ is the only arbiter. :func:`sharefetch.resolve.resolve` derives the
400
+ folder path without asking whether the folder exists, so the mistake
401
+ first shows as a ``404`` when enumeration requests that folder's
402
+ children, and that is where it is answered.
403
+ """
404
+ try:
405
+ return target, self._enumerate(target)
406
+ except EnumerationError as first:
407
+ decode_passes = self._decode_fallback_ceiling(reference, first)
408
+ if decode_passes is None:
409
+ raise
410
+ return self._retry_one_decode_pass_short(reference, target, first, decode_passes)
411
+
412
+ def _decode_fallback_ceiling(
413
+ self, reference: SourceRef, failure: EnumerationError
414
+ ) -> int | None:
415
+ """Return the decode-pass ceiling to retry at, or ``None`` for no retry.
416
+
417
+ The retry is permitted under four conditions and no others. Each is
418
+ tested here, and each logs at DEBUG where it is the one that declined,
419
+ so a run that did not retry says why.
420
+
421
+ The ceiling comes from :func:`sharefetch.resolve.decode_pass_ceiling`,
422
+ which reports the passes this URL's ``id`` parameter actually consumed.
423
+ Subtracting one from ``config.MAX_DECODE_ITERATIONS`` instead would
424
+ change nothing for a link that stabilised after three passes, and the
425
+ retry would silently repeat the first attempt.
426
+ """
427
+ if self._decode_fallback_spent:
428
+ logger.debug("the decode-pass fallback has already been spent on this run")
429
+ return None
430
+ if not isinstance(reference, SitePathRef):
431
+ logger.debug(
432
+ "the source is a sharing link, whose path was never doubly decoded, so it "
433
+ "carries no decode pass to drop"
434
+ )
435
+ return None
436
+ if self._enumeration_produced:
437
+ logger.debug(
438
+ "the walk had already produced output when it failed, so the failure lies "
439
+ "below the addressed folder and re-resolving would answer the wrong fault"
440
+ )
441
+ return None
442
+ status = self._failure_status(failure)
443
+ if status != NOT_FOUND:
444
+ logger.debug(
445
+ "the enumeration failure carried status %s and not %d, so the addressed "
446
+ "folder is not missing and no retry is made",
447
+ status,
448
+ NOT_FOUND,
449
+ )
450
+ return None
451
+ ceiling = decode_pass_ceiling(reference)
452
+ if ceiling < 2:
453
+ logger.debug(
454
+ "the id parameter consumed %d decode pass(es), so no lower ceiling exists "
455
+ "to retry at",
456
+ ceiling,
457
+ )
458
+ return None
459
+ self._decode_fallback_spent = True
460
+ return ceiling - 1
461
+
462
+ def _retry_one_decode_pass_short(
463
+ self,
464
+ reference: SourceRef,
465
+ target: DriveTarget,
466
+ first: EnumerationError,
467
+ decode_passes: int,
468
+ ) -> tuple[DriveTarget, dict[str, ItemState] | None]:
469
+ """Re-resolve at ``decode_passes`` and enumerate the folder that names.
470
+
471
+ The one retry permitted. A second failure, whether the
472
+ retry could not be resolved at all or resolved to a folder that is also
473
+ missing, raises a message naming both folders tried and carrying both
474
+ refusals: an operator reading it has to see which two names the run
475
+ actually looked for. Stepping the ceiling down further is not done,
476
+ because each rung costs a site probe and a drive list and no evidence
477
+ points at a name two passes short.
478
+ """
479
+ status = self._failure_status(first)
480
+ try:
481
+ retried = self._resolver(reference, self._client, decode_passes=decode_passes)
482
+ except AuthError:
483
+ raise
484
+ except SharefetchError as exc:
485
+ raise EnumerationError(
486
+ f"the addressed folder {target.folder_path!r} was not found, and the one retry "
487
+ f"at {decode_passes} decode pass(es) could not be resolved at all: {exc}. The "
488
+ f"first failure was: {first}",
489
+ status=status,
490
+ ) from first
491
+
492
+ logger.info(
493
+ "the addressed folder %r was not found; the id parameter of the source URL "
494
+ "consumed %d decode pass(es), so the run retries once at %d, which addresses %r "
495
+ "instead",
496
+ target.folder_path,
497
+ decode_passes + 1,
498
+ decode_passes,
499
+ retried.folder_path,
500
+ )
501
+
502
+ # The first attempt produced nothing, or the retry would have been
503
+ # declined, so all three are already empty. Clearing them holds that by
504
+ # construction rather than by the caller's reasoning: _enumerate builds
505
+ # its own item map and adds to the remote map this instance shares.
506
+ self._remote.clear()
507
+ self._enumeration_skipped = 0
508
+ self._unmappable = 0
509
+
510
+ try:
511
+ items = self._enumerate(retried)
512
+ except EnumerationError as second:
513
+ raise EnumerationError(
514
+ f"the addressed folder was not found at {target.folder_path!r}, and the one "
515
+ f"retry one decode pass short addressed {retried.folder_path!r}, which was not "
516
+ f"found either. The first failure was: {first}. The retry failed with: {second}",
517
+ status=status,
518
+ ) from first
519
+
520
+ logger.info(
521
+ "%r resolved one decode pass short of the ceiling and the run continues against it",
522
+ retried.folder_path,
523
+ )
524
+ self._announce(retried)
525
+ return retried, items
526
+
527
+ def _failure_status(self, failure: EnumerationError) -> int | None:
528
+ """Return the HTTP status behind an enumeration failure.
529
+
530
+ :func:`sharefetch.listing.walk` reports a refused addressed folder as an
531
+ ``EnumerationError`` carrying the refusal's text and not its status, and
532
+ chains the original refusal as the cause, so the status is read off the
533
+ cause. The HTTP layer also raises ``EnumerationError`` directly for a
534
+ body it cannot read, and those carry the status themselves.
535
+ """
536
+ status = getattr(failure, "status", None)
537
+ if status is not None:
538
+ return status
539
+ return getattr(failure.__cause__, "status", None)
540
+
541
+ def _enumerate(self, target: DriveTarget) -> dict[str, ItemState] | None:
542
+ """Walk the tree and map each item onto a local path.
543
+
544
+ Returns the enumerated items keyed on the drive-relative path, or
545
+ ``None`` where a stop ended the walk before it finished. A partial walk
546
+ is never installed, because
547
+ :meth:`~sharefetch.state.StateStore.replace_enumeration` drops every
548
+ path the walk did not return and a truncated walk would therefore
549
+ discard the records of files it simply never reached.
550
+ """
551
+ settings = self._settings
552
+ items: dict[str, ItemState] = {}
553
+ taken: set[str] = set()
554
+ wanted = target.single_file_name
555
+ # Reset per attempt, because the decode-pass fallback reads it after a
556
+ # failure to tell the addressed folder apart from a folder deeper in.
557
+ self._enumeration_produced = False
558
+
559
+ try:
560
+ for remote in self._walker(
561
+ target,
562
+ self._client,
563
+ include=settings.include,
564
+ exclude=settings.exclude,
565
+ max_depth=settings.max_depth,
566
+ on_progress=self._on_enumeration_progress,
567
+ ):
568
+ self._enumeration_produced = True
569
+ if wanted is not None and remote.name != wanted:
570
+ logger.debug(
571
+ "%s is not the single file the sharing link addressed", remote.drive_path
572
+ )
573
+ continue
574
+ state = self._map_item(remote, taken)
575
+ if state is None:
576
+ continue
577
+ items[remote.drive_path] = state
578
+ self._remote[remote.drive_path] = remote
579
+ except _EnumerationCancelledError:
580
+ return None
581
+
582
+ logger.info("enumeration produced %d transferable item(s)", len(items))
583
+ return items
584
+
585
+ def _map_item(self, remote: RemoteItem, taken: set[str]) -> ItemState | None:
586
+ """Map one enumerated item onto a local path, or report it unmappable.
587
+
588
+ A path whose component survives sanitisation longer than Windows
589
+ accepts is recorded as failed with the reason ``path_too_long``. The
590
+ item has no local path, so it has nowhere in the state file to be
591
+ recorded: it is counted failed for the run and
592
+ reported on its own ``FileFinished`` event.
593
+ """
594
+ try:
595
+ mapped = map_drive_path(remote.drive_path, taken)
596
+ except ValidationError as exc:
597
+ self._unmappable += 1
598
+ message = str(exc)
599
+ logger.error("%s cannot be given a local path: %s", remote.drive_path, message)
600
+ self._sink.emit(
601
+ FileFinished(
602
+ drive_path=remote.drive_path,
603
+ status=STATUS_FAILED,
604
+ verification=None,
605
+ error=f"{REASON_PATH_TOO_LONG}: {message}",
606
+ )
607
+ )
608
+ return None
609
+
610
+ taken.add(str(mapped.local_path).lower())
611
+ return ItemState(
612
+ item_id=remote.item_id,
613
+ size=remote.size,
614
+ etag=remote.etag,
615
+ ctag=remote.ctag,
616
+ mime_type=remote.mime_type,
617
+ quick_xor_hash=remote.quick_xor_hash,
618
+ local_path=str(mapped.local_path),
619
+ sanitised=mapped.sanitised,
620
+ original_name=mapped.original_name,
621
+ )
622
+
623
+ def _on_enumeration_progress(self, event: Any) -> None:
624
+ """Forward a walk event to the sink, and end the walk on a stop.
625
+
626
+ Raising here is the documented way to stop ``listing.walk`` part way
627
+ through, because it is a lazy generator driven by this engine's own
628
+ loop.
629
+
630
+ Any event at all means the addressed folder's first page was read, so
631
+ a later failure lies below it. The decode-pass fallback reads that.
632
+ """
633
+ self._enumeration_produced = True
634
+ if self._stop.is_set():
635
+ raise _EnumerationCancelledError(
636
+ "the run was stopped while the tree was being enumerated"
637
+ )
638
+ if isinstance(event, SkippedItem):
639
+ self._enumeration_skipped += 1
640
+ logger.warning("%s was skipped during enumeration: %s", event.drive_path, event.reason)
641
+ self._sink.emit(
642
+ FileFinished(
643
+ drive_path=event.drive_path,
644
+ status=STATUS_SKIPPED,
645
+ verification=None,
646
+ error=event.reason,
647
+ )
648
+ )
649
+ return
650
+ if isinstance(event, EnumerationComplete):
651
+ logger.info(
652
+ "enumeration complete: %d file(s), %d byte(s), %d folder(s)",
653
+ event.files,
654
+ event.total_bytes,
655
+ event.folders,
656
+ )
657
+ self._sink.emit(event)
658
+
659
+ def _enumeration_header(self, target: DriveTarget) -> dict[str, Any]:
660
+ """The header fields the store accepts from this run's enumeration."""
661
+ return {
662
+ "source_url": self._settings.url,
663
+ "drive_id": target.drive_id,
664
+ "drive_name": target.drive_name,
665
+ "root_folder_path": target.folder_path,
666
+ # The store fills this with its own wall clock, so the recorded
667
+ # instant is the one the enumeration actually finished at.
668
+ "enumerated_at": None,
669
+ }
670
+
671
+ # ------------------------------------------------------------------
672
+ # Free space
673
+ # ------------------------------------------------------------------
674
+
675
+ def _check_free_space(self, remaining: int) -> None:
676
+ """Compare free space against the queue plus its margin.
677
+
678
+ Raises ``ValidationError`` naming both figures in bytes and in MiB.
679
+ ``--ignore-free-space`` skips the check. Checking before the first
680
+ transfer is preferred because exhaustion part way through produces a
681
+ truncated file that reconciliation then has to interpret.
682
+ """
683
+ if self._settings.ignore_free_space:
684
+ logger.info("the free-space check is skipped because --ignore-free-space was given")
685
+ return
686
+ if remaining <= 0:
687
+ logger.info("nothing remains to transfer, so no free space is required")
688
+ return
689
+
690
+ required = int(remaining * (1 + FREE_SPACE_MARGIN))
691
+ try:
692
+ free = int(self._disk_usage(long_path(self._dest)).free)
693
+ except OSError as exc:
694
+ logger.warning(
695
+ "free space on the volume holding %s could not be read, the check is skipped: %s",
696
+ self._dest,
697
+ exc,
698
+ )
699
+ return
700
+
701
+ if free < required:
702
+ raise ValidationError(
703
+ f"the queue needs {required} bytes ({required / BYTES_PER_MIB:.1f} MiB), which is "
704
+ f"{remaining} bytes of content plus the {FREE_SPACE_MARGIN:.0%} margin, and the "
705
+ f"volume holding {self._dest} has {free} bytes ({free / BYTES_PER_MIB:.1f} MiB) "
706
+ f"free; pass --ignore-free-space to proceed anyway"
707
+ )
708
+ logger.info(
709
+ "free space check passed: %d byte(s) required, %d byte(s) available", required, free
710
+ )
711
+
712
+ # ------------------------------------------------------------------
713
+ # The transfer queue
714
+ # ------------------------------------------------------------------
715
+
716
+ def _drain_queue(self) -> None:
717
+ """Transfer every queued item on a pool of ``settings.connections`` workers.
718
+
719
+ A worker returning ``"paused"`` puts its item back at the head of the
720
+ queue, so the file it left partly written is the first one resumed. A
721
+ stop leaves the remaining queue untouched and lets the files already in
722
+ flight run to the end of their bodies.
723
+ """
724
+ store = self._require_store()
725
+ queue = deque(drive_path for drive_path, _ in store.pending())
726
+ if not queue:
727
+ logger.info("nothing is queued for transfer")
728
+ return
729
+
730
+ workers = self._settings.connections
731
+ logger.info("transferring %d item(s) on %d worker(s)", len(queue), workers)
732
+ in_flight: dict[Future[tuple[str, int]], str] = {}
733
+ try:
734
+ with ThreadPoolExecutor(
735
+ max_workers=workers, thread_name_prefix=WORKER_NAME_PREFIX
736
+ ) as pool:
737
+ while queue or in_flight:
738
+ while (
739
+ queue
740
+ and len(in_flight) < workers
741
+ and not self._stop.is_set()
742
+ and not self._pause.is_set()
743
+ ):
744
+ drive_path = queue.popleft()
745
+ in_flight[pool.submit(self._transfer_one, drive_path)] = drive_path
746
+
747
+ if not in_flight:
748
+ if self._stop.is_set():
749
+ logger.info(
750
+ "the run was stopped with %d item(s) still queued", len(queue)
751
+ )
752
+ break
753
+ if self._pause.is_set():
754
+ store.flush(force=True)
755
+ self._wait_while_paused()
756
+ continue
757
+ break
758
+
759
+ done, _ = wait(list(in_flight), return_when=FIRST_COMPLETED)
760
+ for future in done:
761
+ finished = in_flight.pop(future)
762
+ outcome, written = future.result()
763
+ if outcome == STATUS_PAUSED:
764
+ queue.appendleft(finished)
765
+ logger.info("%s was paused and returns to the queue", finished)
766
+ continue
767
+ self._bytes_transferred += written
768
+ if outcome == STATUS_COMPLETE:
769
+ self._transferred += 1
770
+ self._note_recorded_hash_outcome(finished)
771
+ finally:
772
+ store.flush(force=True)
773
+
774
+ def _wait_while_paused(self) -> None:
775
+ """Block the engine thread until the pause is released or a stop arrives."""
776
+ logger.info("the queue is paused and state has been flushed")
777
+ while self._pause.is_set() and not self._stop.is_set():
778
+ self._sleep(PAUSE_POLL_SECONDS)
779
+
780
+ def _transfer_one(self, drive_path: str) -> tuple[str, int]:
781
+ """Fetch one item and return its outcome and the bytes this run wrote.
782
+
783
+ Runs on a worker thread. ``transfer.fetch`` records the item's outcome
784
+ against the store before returning and never re-raises a per-item
785
+ ``TransferError``, so the returned string is everything the queue needs.
786
+ An ``AuthError`` is terminal for the whole run and propagates.
787
+ """
788
+ store = self._require_store()
789
+ record = store.all_items()[drive_path]
790
+ before = record.bytes_written
791
+ outcome = self._fetch(
792
+ self._remote[drive_path],
793
+ store.final_path(record),
794
+ self._client,
795
+ store,
796
+ self._pacer,
797
+ self._settings,
798
+ self._sink,
799
+ self._pause,
800
+ self._stop,
801
+ drive_id=self._drive_id,
802
+ sleep=self._sleep,
803
+ )
804
+ after = store.all_items()[drive_path].bytes_written
805
+ return outcome, max(after - before, 0)
806
+
807
+ # ------------------------------------------------------------------
808
+ # Verification over the whole tree
809
+ # ------------------------------------------------------------------
810
+
811
+ def _verify_tree(self) -> None:
812
+ """Re-run checks 1 to 4 over every file sitting at its final name.
813
+
814
+ A ``.part`` file is deliberately outside the pass: partial bytes are
815
+ not a file in the tree, and verifying one would
816
+ record a size mismatch against an item that is resuming correctly.
817
+ """
818
+ store = self._require_store()
819
+ checked = 0
820
+ for drive_path, item in sorted(store.all_items().items()):
821
+ final = store.final_path(item)
822
+ if not _exists(final):
823
+ continue
824
+ result = self._verify_one(final, drive_path, item)
825
+ if result is None:
826
+ continue
827
+ checked += 1
828
+ failed = result.status == VERIFICATION_FAILED
829
+ store.record(
830
+ drive_path,
831
+ status=STATUS_FAILED if failed else STATUS_COMPLETE,
832
+ verification=result.to_dict(),
833
+ last_error=f"verification failed: {result.reason}" if failed else None,
834
+ )
835
+ self._note_hash_outcome(item.quick_xor_hash, result.checks, result.reason)
836
+ logger.info("the end-of-run pass verified %d file(s) under %s", checked, self._dest)
837
+
838
+ def _verify_one(
839
+ self, path: Path, drive_path: str, item: ItemState
840
+ ) -> VerificationResult | None:
841
+ """Verify one file in the tree, explaining a hash mismatch in full.
842
+
843
+ Checks stop at the first failure, so a file failing check 2 leaves
844
+ checks 3 and 4 unrun and the safeguard with nothing to read. The file is
845
+ verified a second time with no hash to compare and that run's check list
846
+ replaces the truncated one, while the reported reason stays
847
+ ``hash_mismatch`` because check 2 fails first whatever the second run
848
+ finds. This mirrors what the transfer layer records for a file that
849
+ never reached its final name.
850
+ """
851
+ name = drive_path.rsplit("/", 1)[-1]
852
+ try:
853
+ result = self._verify(
854
+ path,
855
+ declared_size=item.size,
856
+ expected_hash=item.quick_xor_hash,
857
+ mime_type=item.mime_type,
858
+ name=name,
859
+ chunk_size=self._settings.chunk_bytes,
860
+ )
861
+ except (VerificationError, OSError) as exc:
862
+ logger.error("%s could not be verified and keeps its recorded status: %s", path, exc)
863
+ return None
864
+
865
+ if result.reason != REASON_HASH_MISMATCH:
866
+ return result
867
+
868
+ try:
869
+ without_hash = self._verify(
870
+ path,
871
+ declared_size=item.size,
872
+ expected_hash=None,
873
+ mime_type=item.mime_type,
874
+ name=name,
875
+ chunk_size=self._settings.chunk_bytes,
876
+ )
877
+ except (VerificationError, OSError) as exc:
878
+ logger.warning("%s could not be re-checked without its hash: %s", drive_path, exc)
879
+ return result
880
+
881
+ logger.info(
882
+ "%s failed the hash comparison while passing %s",
883
+ drive_path,
884
+ ", ".join(without_hash.checks) or "no other check",
885
+ )
886
+ return VerificationResult(
887
+ status=VERIFICATION_FAILED,
888
+ checks=without_hash.checks,
889
+ warnings=without_hash.warnings,
890
+ reason=REASON_HASH_MISMATCH,
891
+ )
892
+
893
+ # ------------------------------------------------------------------
894
+ # The hash implementation safeguard
895
+ # ------------------------------------------------------------------
896
+
897
+ def _note_recorded_hash_outcome(self, drive_path: str) -> None:
898
+ """Read the verdict the transfer layer recorded and tally it."""
899
+ item = self._require_store().all_items().get(drive_path)
900
+ if item is None or not isinstance(item.verification, dict):
901
+ return
902
+ checks = item.verification.get("checks") or ()
903
+ reason = item.verification.get("reason")
904
+ self._note_hash_outcome(item.quick_xor_hash, tuple(checks), reason)
905
+
906
+ def _note_hash_outcome(
907
+ self, expected_hash: str | None, checks: tuple[str, ...], reason: str | None
908
+ ) -> None:
909
+ """Count one file towards the safeguard, where it had a hash to compare.
910
+
911
+ A file whose result lists check 2 among the checks that passed is a
912
+ match. A file whose result names ``hash_mismatch`` while listing check 3
913
+ is a file that failed the hash alone: checks run in order and stop at
914
+ the first failure, so a passing check 3 means check 1 passed or was
915
+ skipped, and the reason names check 2 rather than check 4.
916
+ """
917
+ if not expected_hash:
918
+ return
919
+ if CHECK_CONTENT_HASH in checks:
920
+ self._hash_comparisons += 1
921
+ self._hash_matches += 1
922
+ return
923
+ if reason == REASON_HASH_MISMATCH:
924
+ self._hash_comparisons += 1
925
+ if CHECK_NOT_SUBSTITUTED in checks:
926
+ self._hash_only_failures += 1
927
+
928
+ def _apply_hash_safeguard(self) -> bool | None:
929
+ """Decide the safeguard's verdict and write it to the state header.
930
+
931
+ One match is evidence the implementation agrees with the service, and
932
+ the header records ``True``. Every file that had a hash to compare
933
+ failing check 2 while passing the others points at the implementation
934
+ in this build rather than at the library contents, and the header
935
+ records ``False``. A run with no hash to compare, or one whose hash
936
+ failures were mixed with failures of other checks, reaches no verdict
937
+ and leaves the header as it stands.
938
+ """
939
+ store = self._require_store()
940
+ if self._hash_comparisons == 0:
941
+ logger.info(
942
+ "no verified file carried a quickXorHash, so this run reaches no verdict on the "
943
+ "hash implementation"
944
+ )
945
+ return store.header().get("hash_algorithm_validated")
946
+
947
+ if self._hash_matches:
948
+ logger.info(
949
+ "%d of %d file(s) matched the quickXorHash the source published",
950
+ self._hash_matches,
951
+ self._hash_comparisons,
952
+ )
953
+ store.set_hash_algorithm_validated(True)
954
+ return True
955
+
956
+ if self._hash_only_failures == self._hash_comparisons:
957
+ logger.error(
958
+ "all %d file(s) that carried a quickXorHash failed the hash comparison while "
959
+ "passing every other check; this points at the quickXorHash implementation in "
960
+ "this build rather than at the contents of the library, and the files are left "
961
+ "on disk for inspection",
962
+ self._hash_comparisons,
963
+ )
964
+ store.set_hash_algorithm_validated(False)
965
+ return False
966
+
967
+ logger.warning(
968
+ "none of the %d file(s) carrying a quickXorHash matched it, and %d of them also "
969
+ "failed another check, so no verdict on the hash implementation is recorded",
970
+ self._hash_comparisons,
971
+ self._hash_comparisons - self._hash_only_failures,
972
+ )
973
+ return store.header().get("hash_algorithm_validated")
974
+
975
+ # ------------------------------------------------------------------
976
+ # Reporting
977
+ # ------------------------------------------------------------------
978
+
979
+ def _write_the_manifest(self) -> Path | None:
980
+ """Write ``manifest.csv`` and return its path, or ``None`` on a failure.
981
+
982
+ A manifest that cannot be written is reported at ERROR and does not
983
+ abort the run. The tree and the state file are already on disk, and
984
+ a partly failing operation reports precisely what succeeded, and
985
+ discarding the summary of completed transfers
986
+ because the report file could not be written would do the opposite.
987
+ """
988
+ try:
989
+ return self._write_manifest(self._require_store(), self._dest)
990
+ except ManifestError as exc:
991
+ logger.error("the manifest could not be written and the run continues: %s", exc)
992
+ return None
993
+
994
+ def _finish(self, manifest_path: Path | None) -> RunSummary:
995
+ """Build the summary from the state store, emit ``RunFinished`` and return it.
996
+
997
+ Every count but ``transferred`` and ``bytes_transferred`` is read off
998
+ the store at this moment, so no count can disagree with the item
999
+ statuses the state file and the manifest carry.
1000
+ """
1001
+ store = self._require_store()
1002
+ items = store.all_items()
1003
+ complete = sum(1 for item in items.values() if item.status == STATUS_COMPLETE)
1004
+ failed = sum(1 for item in items.values() if item.status == STATUS_FAILED)
1005
+ skipped = sum(1 for item in items.values() if item.status == STATUS_SKIPPED)
1006
+ verified = sum(
1007
+ 1
1008
+ for item in items.values()
1009
+ if isinstance(item.verification, dict)
1010
+ and item.verification.get("status") != VERIFICATION_FAILED
1011
+ )
1012
+
1013
+ summary = RunSummary(
1014
+ transferred=self._transferred,
1015
+ skipped=skipped + self._enumeration_skipped,
1016
+ failed=failed + self._unmappable,
1017
+ already_present=max(complete - self._transferred, 0),
1018
+ verified=verified,
1019
+ bytes_transferred=self._bytes_transferred,
1020
+ manifest_path=manifest_path,
1021
+ hash_algorithm_validated=store.header().get("hash_algorithm_validated"),
1022
+ )
1023
+ logger.info(
1024
+ "run finished: %d transferred, %d already present, %d verified, %d skipped, %d failed",
1025
+ summary.transferred,
1026
+ summary.already_present,
1027
+ summary.verified,
1028
+ summary.skipped,
1029
+ summary.failed,
1030
+ )
1031
+ self._sink.emit(RunFinished(summary=summary, manifest_path=manifest_path))
1032
+ return summary
1033
+
1034
+ def _stopped_during_enumeration_summary(self) -> RunSummary:
1035
+ """Report a run stopped before any enumeration was installed."""
1036
+ summary = RunSummary(
1037
+ transferred=0,
1038
+ skipped=self._enumeration_skipped,
1039
+ failed=self._unmappable,
1040
+ already_present=0,
1041
+ verified=0,
1042
+ bytes_transferred=0,
1043
+ manifest_path=None,
1044
+ hash_algorithm_validated=None,
1045
+ )
1046
+ self._sink.emit(RunFinished(summary=summary, manifest_path=None))
1047
+ return summary
1048
+
1049
+ def _require_store(self) -> StateStore:
1050
+ """Return the store, which every stage after enumeration depends on."""
1051
+ if self._store is None:
1052
+ raise ValidationError(
1053
+ "the run reached a stage that needs the state store before one was opened"
1054
+ )
1055
+ return self._store