Cat Feeder  1.0.0
The Cat feeder project
Loading...
Searching...
No Matches
archive_to_archive.py
Go to the documentation of this file.
1r"""
2# +==== BEGIN CatFeeder =================+
3# LOGO:
4# ..............(..../\
5# ...............)..(.')
6# ..............(../..)
7# ...............\‍(__)|
8# Inspired by Joan Stark
9# source https://www.asciiart.eu/
10# animals/cats
11# /STOP
12# PROJECT: CatFeeder
13# FILE: archive_to_archive.py
14# CREATION DATE: 15-01-2026
15# LAST Modified: 1:33:19 17-01-2026
16# DESCRIPTION:
17# This is the backend server in charge of making the actual website work.
18# /STOP
19# COPYRIGHT: (c) Cat Feeder
20# PURPOSE: The file containing the code for converting archives from one format to another.
21# // AR
22# +==== END CatFeeder =================+
23"""
24
25from typing import Optional
26from pathlib import Path
27import tempfile
28
29import patoolib
30from display_tty import Disp, initialise_logger
31
32from . import converters_constants as CONV_CONST
33from .aliases import ARCHIVE_FORMAT_ALIASES
34
35from ..http_constants import DataTypes, MEDIA_TYPES
36
37
38from ...core import FinalClass
39from ...utils import CONST
40
41
42class ArchiveToArchive(metaclass=FinalClass):
43 """Class used to convert archives from one format to another using patoolib."""
44
45 disp: Disp = initialise_logger(__qualname__, CONST.DEBUG)
46
47 _instance: Optional["ArchiveToArchive"] = None
48
49 def __new__(cls) -> "ArchiveToArchive":
50 if cls._instance is None:
51 cls._instance = super(ArchiveToArchive, cls).__new__(cls)
52 return cls._instance
53
54 def __init__(self) -> None:
55 self.disp.log_debug("Initialising...")
56 self.disp.log_debug("Initialised.")
57
58 def __call__(self, data: bytes, source_format: DataTypes) -> CONV_CONST.ConversionResult:
59 return self.archive_to_archive(data, source_format)
60
61 @staticmethod
62 def is_archive(path: Path) -> bool:
63 """Check if a file is a valid archive."""
64 try:
65 return patoolib.is_archive(str(path))
66 except Exception:
67 return False
68
69 @staticmethod
70 def split_name_and_ext(path: Path) -> tuple[str, str]:
71 """
72 Handles multi-part extensions like .tar.gz
73
74 Args:
75 path: Path object to split
76
77 Returns:
78 Tuple of (base_name, extension)
79 """
80 name = path.name
81 parts = name.split(".")
82
83 if len(parts) <= 2:
84 return path.stem, path.suffix.lstrip(".")
85
86 # heuristic: treat last 2 parts as extension for compound extensions
87 return parts[0], ".".join(parts[1:])
88
89 def get_archive_extension(self, data_type: DataTypes) -> Optional[str]:
90 """
91 Get the file extension for a given archive DataType.
92
93 Args:
94 data_type: The DataType to get extension for
95
96 Returns:
97 Extension string or None
98 """
99 # Map DataTypes to archive extensions
100 return ARCHIVE_FORMAT_ALIASES.get(data_type)
101
103 self,
104 data: bytes,
105 source_format: DataTypes
106 ) -> tuple[Optional[DataTypes], Optional[str], Optional[str]]:
107 """
108 Validate conversion parameters and get destination format and extensions.
109
110 Args:
111 data: The archive data to validate
112 source_format: The source archive format
113
114 Returns:
115 Tuple of (destination_format, source_ext, dest_ext)
116 Returns (None, None, None) if validation fails
117 """
118 try:
119 destination_format = MEDIA_TYPES.get_conversion_target(
120 source_format)
121 except (AttributeError, NameError):
122 destination_format = None
123
124 if destination_format is None:
125 self.disp.log_debug(f"No conversion target for {source_format}")
126 return None, None, None
127
128 if source_format == destination_format:
129 self.disp.log_debug(
130 f"Source and destination formats are the same: {source_format}"
131 )
132 return destination_format, None, None
133
134 source_ext = self.get_archive_extension(source_format)
135 dest_ext = self.get_archive_extension(destination_format)
136
137 if not source_ext or not dest_ext:
138 self.disp.log_warning(
139 f"Unknown archive extension for {source_format} -> {destination_format}"
140 )
141 return destination_format, None, None
142
143 return destination_format, source_ext, dest_ext
144
146 self,
147 data: bytes,
148 source_ext: str,
149 dest_ext: str
150 ) -> tuple[Path, Path]:
151 """
152 Create temporary files for archive conversion.
153
154 Args:
155 data: The source archive data
156 source_ext: The source file extension
157 dest_ext: The destination file extension
158
159 Returns:
160 Tuple of (source_path, destination_path)
161 """
162 with tempfile.NamedTemporaryFile(
163 suffix=f".{source_ext}",
164 delete=False
165 ) as src_file:
166 src_file.write(data)
167 src_path = Path(src_file.name)
168
169 with tempfile.NamedTemporaryFile(
170 suffix=f".{dest_ext}",
171 delete=False
172 ) as dst_file:
173 dst_path = Path(dst_file.name)
174
175 return src_path, dst_path
176
178 self,
179 src_path: Path,
180 dst_path: Path,
181 source_format: DataTypes,
182 destination_format: DataTypes
183 ) -> Optional[bytes]:
184 """
185 Perform the actual archive conversion using patoolib.
186
187 Args:
188 src_path: Path to source archive file
189 dst_path: Path to destination archive file
190 source_format: Source archive format
191 destination_format: Destination archive format
192
193 Returns:
194 Converted archive data as bytes, or None if conversion failed
195 """
196 if not self.is_archive(src_path):
197 self.disp.log_warning("Input data is not a valid archive")
198 return None
199
200 self.disp.log_debug(
201 f"Converting archive: {source_format} -> {destination_format}"
202 )
203
204 patoolib.repack_archive(
205 str(src_path),
206 str(dst_path),
207 verbosity=-1 # Suppress output
208 )
209
210 with open(dst_path, 'rb') as f:
211 converted_data = f.read()
212
213 self.disp.log_debug("Archive conversion successful")
214 return converted_data
215
216 @staticmethod
217 def _cleanup_temp_files(src_path: Path, dst_path: Path) -> None:
218 """
219 Clean up temporary files.
220
221 Args:
222 src_path: Source file path to delete
223 dst_path: Destination file path to delete
224 """
225 if src_path.exists():
226 src_path.unlink()
227 if dst_path.exists():
228 dst_path.unlink()
229
231 self,
232 data: bytes,
233 source_format: DataTypes,
234 destination_format: DataTypes,
235 result: Optional[bytes] = None
236 ) -> CONV_CONST.ConversionResult:
237 """
238 Create a failed conversion result.
239
240 Args:
241 data: Original archive data
242 source_format: Source archive format
243 destination_format: Destination archive format
244 result: Optional result data
245
246 Returns:
247 ConversionResult indicating failure
248 """
249 return CONV_CONST.ConversionResult(
250 data=data,
251 converted=False,
252 from_type=source_format,
253 to_type=destination_format,
254 result=result
255 )
256
258 self,
259 data: bytes,
260 source_format: DataTypes,
261 destination_format: DataTypes,
262 converted_data: bytes
263 ) -> CONV_CONST.ConversionResult:
264 """
265 Create a successful conversion result.
266
267 Args:
268 data: Original archive data
269 source_format: Source archive format
270 destination_format: Destination archive format
271 converted_data: Converted archive data
272
273 Returns:
274 ConversionResult indicating success
275 """
276 return CONV_CONST.ConversionResult(
277 data=data,
278 converted=True,
279 from_type=source_format,
280 to_type=destination_format,
281 result=converted_data
282 )
283
285 self,
286 data: bytes,
287 source_format: DataTypes,
288 destination_format: Optional[DataTypes],
289 source_ext: Optional[str],
290 dest_ext: Optional[str]
291 ) -> Optional[CONV_CONST.ConversionResult]:
292 """
293 Handle validation failures and return appropriate result.
294
295 Args:
296 data: Original archive data
297 source_format: Source archive format
298 destination_format: Destination format (may be None)
299 source_ext: Source extension (may be None)
300 dest_ext: Destination extension (may be None)
301
302 Returns:
303 ConversionResult if validation failed, None if validation passed
304 """
305 if destination_format is None:
306 return self._create_failed_result(
307 data, source_format, source_format, None
308 )
309
310 if source_ext is None or dest_ext is None:
311 result_data = data if source_format == destination_format else data
312 return self._create_failed_result(
313 data, source_format, destination_format, result_data
314 )
315
316 return None
317
319 self,
320 data: bytes,
321 source_format: DataTypes,
322 destination_format: DataTypes,
323 source_ext: str,
324 dest_ext: str
325 ) -> CONV_CONST.ConversionResult:
326 """
327 Perform conversion using temporary files.
328
329 Args:
330 data: Original archive data
331 source_format: Source archive format
332 destination_format: Destination archive format
333 source_ext: Source file extension
334 dest_ext: Destination file extension
335
336 Returns:
337 ConversionResult with conversion outcome
338 """
339 src_path: Optional[Path] = None
340 dst_path: Optional[Path] = None
341
342 try:
343 src_path, dst_path = self._create_temp_files(
344 data, source_ext, dest_ext
345 )
346
347 converted_data = self._perform_conversion(
348 src_path, dst_path, source_format, destination_format
349 )
350
351 if converted_data is None:
352 return self._create_failed_result(
353 data, source_format, destination_format, None
354 )
355
356 return self._create_success_result(
357 data, source_format, destination_format, converted_data
358 )
359
360 except Exception as e:
361 self.disp.log_error(f"Archive conversion error: {e}")
362 return self._create_failed_result(
363 data, source_format, destination_format, None
364 )
365
366 finally:
367 if src_path is not None and dst_path is not None:
368 self._cleanup_temp_files(src_path, dst_path)
369
370 def archive_to_archive(self, data: bytes, source_format: DataTypes) -> CONV_CONST.ConversionResult:
371 """
372 Convert archive data from one format to another using patoolib.
373
374 Args:
375 data (bytes): The archive data to convert.
376 source_format (DataTypes): The original archive format.
377
378 Returns:
379 ConversionResult: The converted archive data in a contained dataclass.
380 """
381 destination_format, source_ext, dest_ext = self._validate_conversion_params(
382 data, source_format
383 )
384
385 validation_result = self._handle_validation_failure(
386 data, source_format, destination_format, source_ext, dest_ext
387 )
388 if validation_result is not None:
389 return validation_result
390
391 # Type guard: at this point we know these are not None due to validation
392 if destination_format is None or source_ext is None or dest_ext is None:
393 # This should never happen after validation, but satisfies type checker
394 return self._create_failed_result(
395 data, source_format, source_format, None
396 )
397
398 return self._convert_with_temp_files(
399 data, source_format, destination_format, source_ext, dest_ext
400 )
CONV_CONST.ConversionResult _create_success_result(self, bytes data, DataTypes source_format, DataTypes destination_format, bytes converted_data)
tuple[Optional[DataTypes], Optional[str], Optional[str]] _validate_conversion_params(self, bytes data, DataTypes source_format)
CONV_CONST.ConversionResult _convert_with_temp_files(self, bytes data, DataTypes source_format, DataTypes destination_format, str source_ext, str dest_ext)
Optional[CONV_CONST.ConversionResult] _handle_validation_failure(self, bytes data, DataTypes source_format, Optional[DataTypes] destination_format, Optional[str] source_ext, Optional[str] dest_ext)
CONV_CONST.ConversionResult __call__(self, bytes data, DataTypes source_format)
Optional[bytes] _perform_conversion(self, Path src_path, Path dst_path, DataTypes source_format, DataTypes destination_format)
tuple[Path, Path] _create_temp_files(self, bytes data, str source_ext, str dest_ext)
CONV_CONST.ConversionResult archive_to_archive(self, bytes data, DataTypes source_format)
CONV_CONST.ConversionResult _create_failed_result(self, bytes data, DataTypes source_format, DataTypes destination_format, Optional[bytes] result=None)