Coverage for src/drive_package_processing.py: 100%
41 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 17:25 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 17:25 +0000
1"""Package processing utilities.
3This module provides package extraction and processing functionality,
4separating archive handling logic from sync operations per SRP.
5"""
7__author__ = "Mandar Patil (mandarons@pm.me)"
9import gzip
10import os
11import unicodedata
12import zipfile
13from shutil import copyfileobj
15import magic
17from src import configure_icloudpy_logging, get_logger
19# Configure icloudpy logging immediately after import
20configure_icloudpy_logging()
22LOGGER = get_logger()
25def process_package(local_file: str) -> str | None:
26 """Process and extract a downloaded package file.
28 This function handles different archive types (ZIP, gzip) and extracts them
29 to the appropriate location. It also handles Unicode normalization for
30 cross-platform compatibility.
32 Args:
33 local_file: Path to the downloaded package file
35 Returns:
36 Path to the processed file/directory, or False if processing failed
37 """
38 archive_file = local_file
40 # zipfile.is_zipfile() rather than libmagic's MIME string: libmagic reports
41 # "application/octet-stream" for many of Apple's packageDownload zips even though
42 # they begin with PK\x03\x04 and open fine with zipfile. is_zipfile() reads the End
43 # of Central Directory record and is the authority on whether ZipFile() will
44 # succeed, which is all _process_zip_package() needs to know.
45 if zipfile.is_zipfile(local_file):
46 return _process_zip_package(local_file, archive_file)
48 magic_object = magic.Magic(mime=True)
49 file_mime_type = magic_object.from_file(filename=local_file)
51 if file_mime_type == "application/gzip":
52 return _process_gzip_package(local_file, archive_file)
53 else:
54 LOGGER.error(
55 f"Unhandled file type - cannot unpack the package {local_file} ({file_mime_type}). "
56 "The downloaded archive is left in place under the package's name.",
57 )
58 return None
61def _process_zip_package(local_file: str, archive_file: str) -> str:
62 """Process a ZIP package file.
64 Args:
65 local_file: Original file path
66 archive_file: Archive file path
68 Returns:
69 Path to the processed file
70 """
71 archive_file += ".zip"
72 os.rename(local_file, archive_file)
73 LOGGER.info(f"Unpacking {archive_file} to {os.path.dirname(archive_file)}")
74 zipfile.ZipFile(archive_file).extractall(path=os.path.dirname(archive_file))
76 # Handle Unicode normalization for cross-platform compatibility
77 normalized_path = unicodedata.normalize("NFD", local_file)
78 if normalized_path != local_file:
79 os.rename(local_file, normalized_path)
80 local_file = normalized_path
82 os.remove(archive_file)
83 LOGGER.info(f"Successfully unpacked the package {archive_file}.")
84 return local_file
87def _process_gzip_package(local_file: str, archive_file: str) -> str | None:
88 """Process a gzip package file.
90 Args:
91 local_file: Original file path
92 archive_file: Archive file path
94 Returns:
95 Path to the processed file, or None if processing failed
96 """
97 archive_file += ".gz"
98 os.rename(local_file, archive_file)
99 LOGGER.info(f"Unpacking {archive_file} to {os.path.dirname(local_file)}")
101 with gzip.GzipFile(filename=archive_file, mode="rb") as gz_file:
102 with open(file=local_file, mode="wb") as package_file:
103 copyfileobj(gz_file, package_file)
105 os.remove(archive_file)
107 # Recursively process the extracted file (might be another archive)
108 return process_package(local_file=local_file)