Coverage for src/drive_file_download.py: 100%
31 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-08 21:05 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-08 21:05 +0000
1"""File download utilities.
3This module provides file downloading functionality for iCloud Drive sync,
4separating download logic from sync operations per SRP.
5"""
7__author__ = "Mandar Patil (mandarons@pm.me)"
9import os
10from datetime import timezone
11from typing import Any
13from src import DEFAULT_REQUEST_TIMEOUT_SEC, configure_icloudpy_logging, get_logger
14from src.drive_package_processing import process_package
16# Configure icloudpy logging immediately after import
17configure_icloudpy_logging()
19LOGGER = get_logger()
22def download_file(
23 item: Any,
24 local_file: str,
25 timeout: int = DEFAULT_REQUEST_TIMEOUT_SEC,
26 flatten_packages: bool = False,
27) -> str | None:
28 """Download a file from iCloud to local filesystem.
30 This function handles the actual download of files from iCloud, including
31 package detection and processing, and sets the correct modification time.
33 Args:
34 item: iCloud file item to download
35 local_file: Local path to save the file
36 timeout: HTTP read timeout in seconds. Without one a stalled
37 connection blocks its worker thread for the life of the
38 process -- nothing raises, so nothing retries and nothing
39 restarts, and the sync simply stops.
40 flatten_packages: When True, package downloads (``/packageDownload?``
41 URLs) skip the unpack step entirely and stay on disk as a
42 single binary file. Useful for backup-style deployments where
43 bundle-directory semantics aren't needed and single-file
44 storage simplifies dedup / restoration. Opt-in via
45 ``drive.flatten_packages: true`` in config.yaml.
47 Returns:
48 Path to the downloaded/processed file, or None if download failed
49 """
50 if not (item and local_file):
51 return None
53 LOGGER.info(f"Downloading {local_file} ...")
54 try:
55 with item.open(stream=True, timeout=timeout) as response:
56 with open(local_file, "wb") as file_out:
57 for chunk in response.iter_content(4 * 1024 * 1024):
58 file_out.write(chunk)
60 # Check if this is a package that needs processing.
61 #
62 # ``process_package`` now returns the local_file path for both
63 # successful unpacks AND unrecognised mime types (the bytes
64 # are still on disk as a flat bundle — see the docstring on
65 # ``process_package`` for the rationale). It only returns
66 # ``None`` on hard processing failure that leaves the file in
67 # an unusable state. So we surface that as a download failure
68 # but no longer treat "couldn't unpack" as failure when the
69 # downloaded bytes are intact.
70 if response.url and "/packageDownload?" in response.url:
71 processed_file = process_package(
72 local_file=local_file, flatten=flatten_packages,
73 )
74 if processed_file is None:
75 return None
76 local_file = processed_file
78 # Set the file modification time to match the remote file.
79 # iCloudPy produces date_modified via strptime(..., "%Y-%m-%dT%H:%M:%SZ") — always
80 # naive UTC with no tzinfo. replace(tzinfo=UTC) is the correct conversion.
81 item_modified_time = item.date_modified.replace(tzinfo=timezone.utc).timestamp()
82 os.utime(local_file, (item_modified_time, item_modified_time))
84 except Exception as e:
85 # Enhanced error logging with file path context
86 # This catches all exceptions including iCloudPy errors like ObjectNotFoundException
87 error_msg = str(e)
88 if "ObjectNotFoundException" in error_msg or "NOT_FOUND" in error_msg:
89 LOGGER.error(f"File not found in iCloud Drive - {local_file}: {error_msg}")
90 else:
91 LOGGER.error(f"Failed to download {local_file}: {error_msg}")
92 return None
94 return local_file