provide-foundation 0.0.0.dev0__py3-none-any.whl → 0.0.0.dev1__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.
Files changed (92) hide show
  1. provide/foundation/__init__.py +12 -20
  2. provide/foundation/archive/__init__.py +23 -0
  3. provide/foundation/archive/base.py +70 -0
  4. provide/foundation/archive/bzip2.py +157 -0
  5. provide/foundation/archive/gzip.py +159 -0
  6. provide/foundation/archive/operations.py +336 -0
  7. provide/foundation/archive/tar.py +164 -0
  8. provide/foundation/archive/zip.py +203 -0
  9. provide/foundation/config/base.py +2 -2
  10. provide/foundation/config/sync.py +19 -4
  11. provide/foundation/core.py +1 -2
  12. provide/foundation/crypto/__init__.py +2 -0
  13. provide/foundation/crypto/certificates/__init__.py +34 -0
  14. provide/foundation/crypto/certificates/base.py +173 -0
  15. provide/foundation/crypto/certificates/certificate.py +290 -0
  16. provide/foundation/crypto/certificates/factory.py +213 -0
  17. provide/foundation/crypto/certificates/generator.py +138 -0
  18. provide/foundation/crypto/certificates/loader.py +130 -0
  19. provide/foundation/crypto/certificates/operations.py +198 -0
  20. provide/foundation/crypto/certificates/trust.py +107 -0
  21. provide/foundation/eventsets/__init__.py +0 -0
  22. provide/foundation/eventsets/display.py +84 -0
  23. provide/foundation/eventsets/registry.py +160 -0
  24. provide/foundation/eventsets/resolver.py +192 -0
  25. provide/foundation/eventsets/sets/das.py +128 -0
  26. provide/foundation/eventsets/sets/database.py +125 -0
  27. provide/foundation/eventsets/sets/http.py +153 -0
  28. provide/foundation/eventsets/sets/llm.py +139 -0
  29. provide/foundation/eventsets/sets/task_queue.py +107 -0
  30. provide/foundation/eventsets/types.py +70 -0
  31. provide/foundation/hub/components.py +7 -133
  32. provide/foundation/logger/__init__.py +3 -10
  33. provide/foundation/logger/config/logging.py +6 -6
  34. provide/foundation/logger/core.py +0 -2
  35. provide/foundation/logger/custom_processors.py +1 -0
  36. provide/foundation/logger/factories.py +11 -2
  37. provide/foundation/logger/processors/main.py +20 -84
  38. provide/foundation/logger/setup/__init__.py +5 -1
  39. provide/foundation/logger/setup/coordinator.py +75 -23
  40. provide/foundation/logger/setup/processors.py +2 -9
  41. provide/foundation/logger/trace.py +27 -0
  42. provide/foundation/metrics/otel.py +10 -10
  43. provide/foundation/process/lifecycle.py +82 -26
  44. provide/foundation/testing/__init__.py +77 -0
  45. provide/foundation/testing/archive/__init__.py +24 -0
  46. provide/foundation/testing/archive/fixtures.py +217 -0
  47. provide/foundation/testing/common/__init__.py +34 -0
  48. provide/foundation/testing/common/fixtures.py +263 -0
  49. provide/foundation/testing/file/__init__.py +40 -0
  50. provide/foundation/testing/file/fixtures.py +523 -0
  51. provide/foundation/testing/logger.py +41 -11
  52. provide/foundation/testing/mocking/__init__.py +46 -0
  53. provide/foundation/testing/mocking/fixtures.py +331 -0
  54. provide/foundation/testing/process/__init__.py +48 -0
  55. provide/foundation/testing/process/fixtures.py +577 -0
  56. provide/foundation/testing/threading/__init__.py +38 -0
  57. provide/foundation/testing/threading/fixtures.py +520 -0
  58. provide/foundation/testing/time/__init__.py +32 -0
  59. provide/foundation/testing/time/fixtures.py +409 -0
  60. provide/foundation/testing/transport/__init__.py +30 -0
  61. provide/foundation/testing/transport/fixtures.py +280 -0
  62. provide/foundation/tools/__init__.py +58 -0
  63. provide/foundation/tools/base.py +348 -0
  64. provide/foundation/tools/cache.py +266 -0
  65. provide/foundation/tools/downloader.py +213 -0
  66. provide/foundation/tools/installer.py +254 -0
  67. provide/foundation/tools/registry.py +223 -0
  68. provide/foundation/tools/resolver.py +321 -0
  69. provide/foundation/tools/verifier.py +186 -0
  70. provide/foundation/tracer/otel.py +7 -11
  71. provide/foundation/transport/__init__.py +155 -0
  72. provide/foundation/transport/base.py +171 -0
  73. provide/foundation/transport/client.py +266 -0
  74. provide/foundation/transport/config.py +209 -0
  75. provide/foundation/transport/errors.py +79 -0
  76. provide/foundation/transport/http.py +232 -0
  77. provide/foundation/transport/middleware.py +366 -0
  78. provide/foundation/transport/registry.py +167 -0
  79. provide/foundation/transport/types.py +45 -0
  80. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/METADATA +5 -28
  81. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/RECORD +85 -34
  82. provide/foundation/cli/commands/logs/generate_old.py +0 -569
  83. provide/foundation/crypto/certificates.py +0 -896
  84. provide/foundation/logger/emoji/__init__.py +0 -44
  85. provide/foundation/logger/emoji/matrix.py +0 -209
  86. provide/foundation/logger/emoji/sets.py +0 -458
  87. provide/foundation/logger/emoji/types.py +0 -56
  88. provide/foundation/logger/setup/emoji_resolver.py +0 -64
  89. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/WHEEL +0 -0
  90. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/entry_points.txt +0 -0
  91. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/licenses/LICENSE +0 -0
  92. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,336 @@
1
+ """Archive operation chains and helpers."""
2
+
3
+ import tempfile
4
+ from pathlib import Path
5
+ from typing import Callable, Optional
6
+
7
+ from attrs import define, field
8
+
9
+ from provide.foundation.archive.base import ArchiveError
10
+ from provide.foundation.archive.tar import TarArchive
11
+ from provide.foundation.archive.gzip import GzipCompressor
12
+ from provide.foundation.archive.bzip2 import Bzip2Compressor
13
+ from provide.foundation.archive.zip import ZipArchive
14
+ from provide.foundation.file import ensure_parent_dir
15
+ from provide.foundation.logger import get_logger
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ @define(slots=True)
21
+ class OperationChain:
22
+ """
23
+ Chain multiple archive operations together.
24
+
25
+ Enables complex operations like tar.gz, tar.bz2, etc.
26
+ Operations are executed in order for creation, reversed for extraction.
27
+ """
28
+
29
+ operations: list[str] = field(factory=list)
30
+
31
+ def execute(self, source: Path, output: Path) -> Path:
32
+ """
33
+ Execute operation chain on source.
34
+
35
+ Args:
36
+ source: Source file or directory
37
+ output: Final output path
38
+
39
+ Returns:
40
+ Path to final output
41
+
42
+ Raises:
43
+ ArchiveError: If any operation fails
44
+ """
45
+ current = source
46
+ temp_files = []
47
+
48
+ try:
49
+ for i, op in enumerate(self.operations):
50
+ # Determine output for this operation
51
+ if i == len(self.operations) - 1:
52
+ # Last operation, use final output
53
+ next_output = output
54
+ else:
55
+ # Intermediate operation, use temp file
56
+ suffix = self._get_suffix_for_operation(op)
57
+ temp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
58
+ temp.close()
59
+ next_output = Path(temp.name)
60
+ temp_files.append(next_output)
61
+
62
+ # Execute operation
63
+ current = self._execute_operation(op, current, next_output)
64
+ logger.debug(f"Executed operation '{op}': {current}")
65
+
66
+ return current
67
+
68
+ except Exception as e:
69
+ raise ArchiveError(f"Operation chain failed: {e}") from e
70
+ finally:
71
+ # Clean up temp files
72
+ for temp in temp_files:
73
+ try:
74
+ temp.unlink()
75
+ except Exception:
76
+ pass
77
+
78
+ def reverse(self, source: Path, output: Path) -> Path:
79
+ """
80
+ Reverse operation chain (extract/decompress).
81
+
82
+ Args:
83
+ source: Source archive
84
+ output: Final output path
85
+
86
+ Returns:
87
+ Path to final output
88
+
89
+ Raises:
90
+ ArchiveError: If any operation fails
91
+ """
92
+ # Reverse the operations and invert them
93
+ reverse_map = {
94
+ 'tar': 'untar',
95
+ 'untar': 'tar',
96
+ 'gzip': 'gunzip',
97
+ 'gunzip': 'gzip',
98
+ 'bzip2': 'bunzip2',
99
+ 'bunzip2': 'bzip2',
100
+ 'zip': 'unzip',
101
+ 'unzip': 'zip',
102
+ }
103
+
104
+ reversed_ops = []
105
+ for op in reversed(self.operations):
106
+ reversed_op = reverse_map.get(op.lower(), op)
107
+ reversed_ops.append(reversed_op)
108
+
109
+ reversed_chain = OperationChain(operations=reversed_ops)
110
+ return reversed_chain.execute(source, output)
111
+
112
+ def _execute_operation(self, operation: str, source: Path, output: Path) -> Path:
113
+ """Execute a single operation."""
114
+ match operation.lower():
115
+ case "tar":
116
+ tar = TarArchive()
117
+ return tar.create(source, output)
118
+ case "untar":
119
+ tar = TarArchive()
120
+ return tar.extract(source, output)
121
+ case "gzip":
122
+ gzip = GzipCompressor()
123
+ return gzip.compress_file(source, output)
124
+ case "gunzip":
125
+ gzip = GzipCompressor()
126
+ return gzip.decompress_file(source, output)
127
+ case "bzip2":
128
+ bz2 = Bzip2Compressor()
129
+ return bz2.compress_file(source, output)
130
+ case "bunzip2":
131
+ bz2 = Bzip2Compressor()
132
+ return bz2.decompress_file(source, output)
133
+ case "zip":
134
+ zip_archive = ZipArchive()
135
+ return zip_archive.create(source, output)
136
+ case "unzip":
137
+ zip_archive = ZipArchive()
138
+ return zip_archive.extract(source, output)
139
+ case _:
140
+ raise ArchiveError(f"Unknown operation: {operation}")
141
+
142
+ def _get_suffix_for_operation(self, operation: str) -> str:
143
+ """Get file suffix for operation."""
144
+ suffixes = {
145
+ "tar": ".tar",
146
+ "gzip": ".gz",
147
+ "bzip2": ".bz2",
148
+ "zip": ".zip",
149
+ }
150
+ return suffixes.get(operation.lower(), ".tmp")
151
+
152
+
153
+ class ArchiveOperations:
154
+ """
155
+ Helper class for common archive operation patterns.
156
+
157
+ Provides convenient methods for common archive formats.
158
+ """
159
+
160
+ @staticmethod
161
+ def create_tar_gz(source: Path, output: Path, deterministic: bool = True) -> Path:
162
+ """
163
+ Create .tar.gz archive in one step.
164
+
165
+ Args:
166
+ source: Source file or directory
167
+ output: Output path (should end with .tar.gz)
168
+ deterministic: Create reproducible archive
169
+
170
+ Returns:
171
+ Path to created archive
172
+
173
+ Raises:
174
+ ArchiveError: If creation fails
175
+ """
176
+ ensure_parent_dir(output)
177
+
178
+ # Create temp tar file
179
+ temp_tar = output.with_suffix('.tar')
180
+ try:
181
+ tar = TarArchive(deterministic=deterministic)
182
+ tar.create(source, temp_tar)
183
+
184
+ # Compress to final output
185
+ gzip = GzipCompressor()
186
+ return gzip.compress_file(temp_tar, output)
187
+ finally:
188
+ # Clean up temp file
189
+ if temp_tar.exists():
190
+ temp_tar.unlink()
191
+
192
+ @staticmethod
193
+ def extract_tar_gz(archive: Path, output: Path) -> Path:
194
+ """
195
+ Extract .tar.gz archive in one step.
196
+
197
+ Args:
198
+ archive: Archive path
199
+ output: Output directory
200
+
201
+ Returns:
202
+ Path to extraction directory
203
+
204
+ Raises:
205
+ ArchiveError: If extraction fails
206
+ """
207
+ output.mkdir(parents=True, exist_ok=True)
208
+
209
+ # Decompress to temp file
210
+ with tempfile.NamedTemporaryFile(suffix='.tar', delete=False) as temp:
211
+ temp_tar = Path(temp.name)
212
+
213
+ try:
214
+ gzip = GzipCompressor()
215
+ gzip.decompress_file(archive, temp_tar)
216
+
217
+ # Extract tar
218
+ tar = TarArchive()
219
+ return tar.extract(temp_tar, output)
220
+ finally:
221
+ # Clean up temp file
222
+ if temp_tar.exists():
223
+ temp_tar.unlink()
224
+
225
+ @staticmethod
226
+ def create_tar_bz2(source: Path, output: Path, deterministic: bool = True) -> Path:
227
+ """
228
+ Create .tar.bz2 archive in one step.
229
+
230
+ Args:
231
+ source: Source file or directory
232
+ output: Output path (should end with .tar.bz2)
233
+ deterministic: Create reproducible archive
234
+
235
+ Returns:
236
+ Path to created archive
237
+
238
+ Raises:
239
+ ArchiveError: If creation fails
240
+ """
241
+ ensure_parent_dir(output)
242
+
243
+ # Create temp tar file
244
+ temp_tar = output.with_suffix('.tar')
245
+ try:
246
+ tar = TarArchive(deterministic=deterministic)
247
+ tar.create(source, temp_tar)
248
+
249
+ # Compress to final output
250
+ bz2 = Bzip2Compressor()
251
+ return bz2.compress_file(temp_tar, output)
252
+ finally:
253
+ # Clean up temp file
254
+ if temp_tar.exists():
255
+ temp_tar.unlink()
256
+
257
+ @staticmethod
258
+ def extract_tar_bz2(archive: Path, output: Path) -> Path:
259
+ """
260
+ Extract .tar.bz2 archive in one step.
261
+
262
+ Args:
263
+ archive: Archive path
264
+ output: Output directory
265
+
266
+ Returns:
267
+ Path to extraction directory
268
+
269
+ Raises:
270
+ ArchiveError: If extraction fails
271
+ """
272
+ output.mkdir(parents=True, exist_ok=True)
273
+
274
+ # Decompress to temp file
275
+ with tempfile.NamedTemporaryFile(suffix='.tar', delete=False) as temp:
276
+ temp_tar = Path(temp.name)
277
+
278
+ try:
279
+ bz2 = Bzip2Compressor()
280
+ bz2.decompress_file(archive, temp_tar)
281
+
282
+ # Extract tar
283
+ tar = TarArchive()
284
+ return tar.extract(temp_tar, output)
285
+ finally:
286
+ # Clean up temp file
287
+ if temp_tar.exists():
288
+ temp_tar.unlink()
289
+
290
+ @staticmethod
291
+ def detect_format(file: Path) -> list[str]:
292
+ """
293
+ Detect archive format and return operation chain.
294
+
295
+ Args:
296
+ file: File path to analyze
297
+
298
+ Returns:
299
+ List of operations needed to extract
300
+
301
+ Raises:
302
+ ArchiveError: If format cannot be detected
303
+ """
304
+ name = file.name.lower()
305
+
306
+ # Check by extension
307
+ if name.endswith('.tar.gz') or name.endswith('.tgz'):
308
+ return ['gunzip', 'untar']
309
+ elif name.endswith('.tar.bz2') or name.endswith('.tbz2'):
310
+ return ['bunzip2', 'untar']
311
+ elif name.endswith('.tar'):
312
+ return ['untar']
313
+ elif name.endswith('.gz'):
314
+ return ['gunzip']
315
+ elif name.endswith('.bz2'):
316
+ return ['bunzip2']
317
+ elif name.endswith('.zip'):
318
+ return ['unzip']
319
+
320
+ # Check by magic numbers
321
+ try:
322
+ with open(file, 'rb') as f:
323
+ magic = f.read(4)
324
+
325
+ if magic[:2] == b'\x1f\x8b': # gzip
326
+ return ['gunzip']
327
+ elif magic[:3] == b'BZh': # bzip2
328
+ return ['bunzip2']
329
+ elif magic[:4] == b'PK\x03\x04': # zip
330
+ return ['unzip']
331
+ elif magic[:3] == b'ustar': # tar (at offset 257)
332
+ return ['untar']
333
+ except Exception:
334
+ pass
335
+
336
+ raise ArchiveError(f"Cannot detect format of {file}")
@@ -0,0 +1,164 @@
1
+ """TAR archive implementation."""
2
+
3
+ import tarfile
4
+ from pathlib import Path
5
+
6
+ from attrs import define, field
7
+
8
+ from provide.foundation.archive.base import BaseArchive, ArchiveError
9
+ from provide.foundation.file import ensure_parent_dir
10
+ from provide.foundation.logger import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ @define(slots=True)
16
+ class TarArchive(BaseArchive):
17
+ """
18
+ TAR archive implementation.
19
+
20
+ Creates and extracts TAR archives with optional metadata preservation
21
+ and deterministic output for reproducible builds.
22
+ """
23
+
24
+ deterministic: bool = field(default=True)
25
+ preserve_metadata: bool = field(default=True)
26
+ preserve_permissions: bool = field(default=True)
27
+
28
+ def create(self, source: Path, output: Path) -> Path:
29
+ """
30
+ Create TAR archive from source.
31
+
32
+ Args:
33
+ source: Source file or directory to archive
34
+ output: Output TAR file path
35
+
36
+ Returns:
37
+ Path to created archive
38
+
39
+ Raises:
40
+ ArchiveError: If archive creation fails
41
+ """
42
+ try:
43
+ ensure_parent_dir(output)
44
+
45
+ with tarfile.open(output, "w") as tar:
46
+ if source.is_dir():
47
+ # Add all files in directory
48
+ for item in sorted(source.rglob("*")):
49
+ if item.is_file():
50
+ arcname = item.relative_to(source.parent)
51
+ self._add_file(tar, item, arcname)
52
+ else:
53
+ # Add single file
54
+ self._add_file(tar, source, source.name)
55
+
56
+ logger.debug(f"Created TAR archive: {output}")
57
+ return output
58
+
59
+ except Exception as e:
60
+ raise ArchiveError(f"Failed to create TAR archive: {e}") from e
61
+
62
+ def extract(self, archive: Path, output: Path) -> Path:
63
+ """
64
+ Extract TAR archive to output directory.
65
+
66
+ Args:
67
+ archive: TAR archive file path
68
+ output: Output directory path
69
+
70
+ Returns:
71
+ Path to extraction directory
72
+
73
+ Raises:
74
+ ArchiveError: If extraction fails
75
+ """
76
+ try:
77
+ output.mkdir(parents=True, exist_ok=True)
78
+
79
+ with tarfile.open(archive, "r") as tar:
80
+ # Security check - prevent path traversal
81
+ for member in tar.getmembers():
82
+ if member.name.startswith("/") or ".." in member.name:
83
+ raise ArchiveError(f"Unsafe path in archive: {member.name}")
84
+
85
+ # Extract all
86
+ tar.extractall(output)
87
+
88
+ logger.debug(f"Extracted TAR archive to: {output}")
89
+ return output
90
+
91
+ except Exception as e:
92
+ raise ArchiveError(f"Failed to extract TAR archive: {e}") from e
93
+
94
+ def validate(self, archive: Path) -> bool:
95
+ """
96
+ Validate TAR archive integrity.
97
+
98
+ Args:
99
+ archive: TAR archive file path
100
+
101
+ Returns:
102
+ True if archive is valid, False otherwise
103
+ """
104
+ try:
105
+ with tarfile.open(archive, "r") as tar:
106
+ # Try to read all members
107
+ for member in tar.getmembers():
108
+ # Just checking we can read the metadata
109
+ pass
110
+ return True
111
+ except Exception:
112
+ return False
113
+
114
+ def list_contents(self, archive: Path) -> list[str]:
115
+ """
116
+ List contents of TAR archive.
117
+
118
+ Args:
119
+ archive: TAR archive file path
120
+
121
+ Returns:
122
+ List of file paths in archive
123
+
124
+ Raises:
125
+ ArchiveError: If listing fails
126
+ """
127
+ try:
128
+ contents = []
129
+ with tarfile.open(archive, "r") as tar:
130
+ for member in tar.getmembers():
131
+ if member.isfile():
132
+ contents.append(member.name)
133
+ return sorted(contents)
134
+ except Exception as e:
135
+ raise ArchiveError(f"Failed to list TAR contents: {e}") from e
136
+
137
+ def _add_file(self, tar: tarfile.TarFile, file_path: Path, arcname: str | Path) -> None:
138
+ """
139
+ Add single file to TAR archive.
140
+
141
+ Args:
142
+ tar: Open TarFile object
143
+ file_path: Path to file to add
144
+ arcname: Name in archive
145
+ """
146
+ tarinfo = tar.gettarinfo(str(file_path), str(arcname))
147
+
148
+ if self.deterministic:
149
+ # Set consistent metadata for reproducible archives
150
+ tarinfo.uid = 0
151
+ tarinfo.gid = 0
152
+ tarinfo.uname = ""
153
+ tarinfo.gname = ""
154
+ tarinfo.mtime = 0
155
+
156
+ if not self.preserve_permissions:
157
+ # Normalize permissions
158
+ if tarinfo.isfile():
159
+ tarinfo.mode = 0o644
160
+ elif tarinfo.isdir():
161
+ tarinfo.mode = 0o755
162
+
163
+ with open(file_path, "rb") as f:
164
+ tar.addfile(tarinfo, f)
@@ -0,0 +1,203 @@
1
+ """ZIP archive implementation."""
2
+
3
+ import zipfile
4
+ from pathlib import Path
5
+ from attrs import define, field
6
+
7
+ from provide.foundation.archive.base import BaseArchive, ArchiveError
8
+ from provide.foundation.file import ensure_parent_dir
9
+ from provide.foundation.logger import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ @define(slots=True)
15
+ class ZipArchive(BaseArchive):
16
+ """
17
+ ZIP archive implementation.
18
+
19
+ Creates and extracts ZIP archives with optional compression and encryption.
20
+ Supports adding files to existing archives.
21
+ """
22
+
23
+ compression_level: int = field(default=6) # Compression level 0-9 (0=store, 9=best)
24
+ compression_type: int = field(default=zipfile.ZIP_DEFLATED)
25
+ password: bytes | None = field(default=None)
26
+
27
+ @compression_level.validator
28
+ def _validate_level(self, attribute, value):
29
+ if not 0 <= value <= 9:
30
+ raise ValueError(f"Compression level must be 0-9, got {value}")
31
+
32
+ def create(self, source: Path, output: Path) -> Path:
33
+ """
34
+ Create ZIP archive from source.
35
+
36
+ Args:
37
+ source: Source file or directory to archive
38
+ output: Output ZIP file path
39
+
40
+ Returns:
41
+ Path to created archive
42
+
43
+ Raises:
44
+ ArchiveError: If archive creation fails
45
+ """
46
+ try:
47
+ ensure_parent_dir(output)
48
+
49
+ with zipfile.ZipFile(
50
+ output, 'w',
51
+ compression=self.compression_type,
52
+ compresslevel=self.compression_level
53
+ ) as zf:
54
+ if self.password:
55
+ zf.setpassword(self.password)
56
+
57
+ if source.is_dir():
58
+ # Add all files in directory
59
+ for item in sorted(source.rglob("*")):
60
+ if item.is_file():
61
+ arcname = item.relative_to(source)
62
+ zf.write(item, arcname)
63
+ else:
64
+ # Add single file
65
+ zf.write(source, source.name)
66
+
67
+ logger.debug(f"Created ZIP archive: {output}")
68
+ return output
69
+
70
+ except Exception as e:
71
+ raise ArchiveError(f"Failed to create ZIP archive: {e}") from e
72
+
73
+ def extract(self, archive: Path, output: Path) -> Path:
74
+ """
75
+ Extract ZIP archive to output directory.
76
+
77
+ Args:
78
+ archive: ZIP archive file path
79
+ output: Output directory path
80
+
81
+ Returns:
82
+ Path to extraction directory
83
+
84
+ Raises:
85
+ ArchiveError: If extraction fails
86
+ """
87
+ try:
88
+ output.mkdir(parents=True, exist_ok=True)
89
+
90
+ with zipfile.ZipFile(archive, 'r') as zf:
91
+ if self.password:
92
+ zf.setpassword(self.password)
93
+
94
+ # Security check - prevent path traversal
95
+ for member in zf.namelist():
96
+ if member.startswith("/") or ".." in member:
97
+ raise ArchiveError(f"Unsafe path in archive: {member}")
98
+
99
+ # Extract all
100
+ zf.extractall(output)
101
+
102
+ logger.debug(f"Extracted ZIP archive to: {output}")
103
+ return output
104
+
105
+ except Exception as e:
106
+ raise ArchiveError(f"Failed to extract ZIP archive: {e}") from e
107
+
108
+ def validate(self, archive: Path) -> bool:
109
+ """
110
+ Validate ZIP archive integrity.
111
+
112
+ Args:
113
+ archive: ZIP archive file path
114
+
115
+ Returns:
116
+ True if archive is valid, False otherwise
117
+ """
118
+ try:
119
+ with zipfile.ZipFile(archive, 'r') as zf:
120
+ # Test the archive
121
+ result = zf.testzip()
122
+ return result is None # None means no bad files
123
+ except Exception:
124
+ return False
125
+
126
+ def list_contents(self, archive: Path) -> list[str]:
127
+ """
128
+ List contents of ZIP archive.
129
+
130
+ Args:
131
+ archive: ZIP archive file path
132
+
133
+ Returns:
134
+ List of file paths in archive
135
+
136
+ Raises:
137
+ ArchiveError: If listing fails
138
+ """
139
+ try:
140
+ with zipfile.ZipFile(archive, 'r') as zf:
141
+ return sorted(zf.namelist())
142
+ except Exception as e:
143
+ raise ArchiveError(f"Failed to list ZIP contents: {e}") from e
144
+
145
+ def add_file(self, archive: Path, file: Path, arcname: str | None = None) -> None:
146
+ """
147
+ Add file to existing ZIP archive.
148
+
149
+ Args:
150
+ archive: ZIP archive file path
151
+ file: File to add
152
+ arcname: Name in archive (defaults to file name)
153
+
154
+ Raises:
155
+ ArchiveError: If adding file fails
156
+ """
157
+ try:
158
+ with zipfile.ZipFile(archive, 'a', compression=self.compression_type) as zf:
159
+ if self.password:
160
+ zf.setpassword(self.password)
161
+
162
+ zf.write(file, arcname or file.name)
163
+
164
+ logger.debug(f"Added {file} to ZIP archive {archive}")
165
+
166
+ except Exception as e:
167
+ raise ArchiveError(f"Failed to add file to ZIP: {e}") from e
168
+
169
+ def extract_file(self, archive: Path, member: str, output: Path) -> Path:
170
+ """
171
+ Extract single file from ZIP archive.
172
+
173
+ Args:
174
+ archive: ZIP archive file path
175
+ member: Name of file in archive
176
+ output: Output directory or file path
177
+
178
+ Returns:
179
+ Path to extracted file
180
+
181
+ Raises:
182
+ ArchiveError: If extraction fails
183
+ """
184
+ try:
185
+ with zipfile.ZipFile(archive, 'r') as zf:
186
+ if self.password:
187
+ zf.setpassword(self.password)
188
+
189
+ # Security check
190
+ if member.startswith("/") or ".." in member:
191
+ raise ArchiveError(f"Unsafe path: {member}")
192
+
193
+ if output.is_dir():
194
+ zf.extract(member, output)
195
+ return output / member
196
+ else:
197
+ ensure_parent_dir(output)
198
+ with zf.open(member) as source, open(output, 'wb') as target:
199
+ target.write(source.read())
200
+ return output
201
+
202
+ except Exception as e:
203
+ raise ArchiveError(f"Failed to extract file from ZIP: {e}") from e