provide-foundation 0.0.0.dev0__py3-none-any.whl → 0.0.0.dev2__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 (161) hide show
  1. provide/foundation/__init__.py +41 -23
  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 +334 -0
  7. provide/foundation/archive/tar.py +164 -0
  8. provide/foundation/archive/zip.py +203 -0
  9. provide/foundation/cli/__init__.py +2 -2
  10. provide/foundation/cli/commands/deps.py +13 -7
  11. provide/foundation/cli/commands/logs/__init__.py +1 -1
  12. provide/foundation/cli/commands/logs/query.py +1 -1
  13. provide/foundation/cli/commands/logs/send.py +1 -1
  14. provide/foundation/cli/commands/logs/tail.py +1 -1
  15. provide/foundation/cli/decorators.py +11 -10
  16. provide/foundation/cli/main.py +1 -1
  17. provide/foundation/cli/testing.py +2 -35
  18. provide/foundation/cli/utils.py +21 -17
  19. provide/foundation/config/__init__.py +35 -2
  20. provide/foundation/config/base.py +2 -2
  21. provide/foundation/config/converters.py +479 -0
  22. provide/foundation/config/defaults.py +67 -0
  23. provide/foundation/config/env.py +4 -19
  24. provide/foundation/config/loader.py +9 -3
  25. provide/foundation/config/sync.py +19 -4
  26. provide/foundation/console/input.py +5 -5
  27. provide/foundation/console/output.py +35 -13
  28. provide/foundation/context/__init__.py +8 -4
  29. provide/foundation/context/core.py +85 -109
  30. provide/foundation/core.py +1 -2
  31. provide/foundation/crypto/__init__.py +2 -0
  32. provide/foundation/crypto/certificates/__init__.py +34 -0
  33. provide/foundation/crypto/certificates/base.py +173 -0
  34. provide/foundation/crypto/certificates/certificate.py +290 -0
  35. provide/foundation/crypto/certificates/factory.py +213 -0
  36. provide/foundation/crypto/certificates/generator.py +138 -0
  37. provide/foundation/crypto/certificates/loader.py +130 -0
  38. provide/foundation/crypto/certificates/operations.py +198 -0
  39. provide/foundation/crypto/certificates/trust.py +107 -0
  40. provide/foundation/errors/__init__.py +2 -3
  41. provide/foundation/errors/decorators.py +0 -231
  42. provide/foundation/errors/types.py +0 -97
  43. provide/foundation/eventsets/__init__.py +0 -0
  44. provide/foundation/eventsets/display.py +84 -0
  45. provide/foundation/eventsets/registry.py +160 -0
  46. provide/foundation/eventsets/resolver.py +192 -0
  47. provide/foundation/eventsets/sets/das.py +128 -0
  48. provide/foundation/eventsets/sets/database.py +125 -0
  49. provide/foundation/eventsets/sets/http.py +153 -0
  50. provide/foundation/eventsets/sets/llm.py +139 -0
  51. provide/foundation/eventsets/sets/task_queue.py +107 -0
  52. provide/foundation/eventsets/types.py +70 -0
  53. provide/foundation/file/directory.py +13 -22
  54. provide/foundation/file/lock.py +3 -1
  55. provide/foundation/hub/components.py +77 -515
  56. provide/foundation/hub/config.py +151 -0
  57. provide/foundation/hub/discovery.py +62 -0
  58. provide/foundation/hub/handlers.py +81 -0
  59. provide/foundation/hub/lifecycle.py +194 -0
  60. provide/foundation/hub/manager.py +4 -4
  61. provide/foundation/hub/processors.py +44 -0
  62. provide/foundation/integrations/__init__.py +11 -0
  63. provide/foundation/{observability → integrations}/openobserve/__init__.py +10 -7
  64. provide/foundation/{observability → integrations}/openobserve/auth.py +1 -1
  65. provide/foundation/{observability → integrations}/openobserve/client.py +12 -12
  66. provide/foundation/{observability → integrations}/openobserve/commands.py +3 -3
  67. provide/foundation/integrations/openobserve/config.py +37 -0
  68. provide/foundation/{observability → integrations}/openobserve/formatters.py +1 -1
  69. provide/foundation/{observability → integrations}/openobserve/otlp.py +1 -1
  70. provide/foundation/{observability → integrations}/openobserve/search.py +2 -2
  71. provide/foundation/{observability → integrations}/openobserve/streaming.py +4 -4
  72. provide/foundation/logger/__init__.py +3 -10
  73. provide/foundation/logger/config/logging.py +68 -298
  74. provide/foundation/logger/config/telemetry.py +41 -121
  75. provide/foundation/logger/core.py +0 -2
  76. provide/foundation/logger/custom_processors.py +1 -0
  77. provide/foundation/logger/factories.py +11 -2
  78. provide/foundation/logger/processors/main.py +20 -84
  79. provide/foundation/logger/setup/__init__.py +5 -1
  80. provide/foundation/logger/setup/coordinator.py +76 -24
  81. provide/foundation/logger/setup/processors.py +2 -9
  82. provide/foundation/logger/trace.py +27 -0
  83. provide/foundation/metrics/otel.py +10 -10
  84. provide/foundation/observability/__init__.py +2 -2
  85. provide/foundation/process/__init__.py +9 -0
  86. provide/foundation/process/exit.py +47 -0
  87. provide/foundation/process/lifecycle.py +115 -59
  88. provide/foundation/resilience/__init__.py +35 -0
  89. provide/foundation/resilience/circuit.py +164 -0
  90. provide/foundation/resilience/decorators.py +220 -0
  91. provide/foundation/resilience/fallback.py +193 -0
  92. provide/foundation/resilience/retry.py +325 -0
  93. provide/foundation/streams/config.py +79 -0
  94. provide/foundation/streams/console.py +7 -8
  95. provide/foundation/streams/core.py +6 -3
  96. provide/foundation/streams/file.py +12 -2
  97. provide/foundation/testing/__init__.py +84 -2
  98. provide/foundation/testing/archive/__init__.py +24 -0
  99. provide/foundation/testing/archive/fixtures.py +217 -0
  100. provide/foundation/testing/cli.py +30 -17
  101. provide/foundation/testing/common/__init__.py +32 -0
  102. provide/foundation/testing/common/fixtures.py +236 -0
  103. provide/foundation/testing/file/__init__.py +40 -0
  104. provide/foundation/testing/file/content_fixtures.py +316 -0
  105. provide/foundation/testing/file/directory_fixtures.py +107 -0
  106. provide/foundation/testing/file/fixtures.py +52 -0
  107. provide/foundation/testing/file/special_fixtures.py +153 -0
  108. provide/foundation/testing/logger.py +117 -11
  109. provide/foundation/testing/mocking/__init__.py +46 -0
  110. provide/foundation/testing/mocking/fixtures.py +331 -0
  111. provide/foundation/testing/process/__init__.py +48 -0
  112. provide/foundation/testing/process/async_fixtures.py +405 -0
  113. provide/foundation/testing/process/fixtures.py +56 -0
  114. provide/foundation/testing/process/subprocess_fixtures.py +209 -0
  115. provide/foundation/testing/threading/__init__.py +38 -0
  116. provide/foundation/testing/threading/basic_fixtures.py +101 -0
  117. provide/foundation/testing/threading/data_fixtures.py +99 -0
  118. provide/foundation/testing/threading/execution_fixtures.py +263 -0
  119. provide/foundation/testing/threading/fixtures.py +54 -0
  120. provide/foundation/testing/threading/sync_fixtures.py +97 -0
  121. provide/foundation/testing/time/__init__.py +32 -0
  122. provide/foundation/testing/time/fixtures.py +409 -0
  123. provide/foundation/testing/transport/__init__.py +30 -0
  124. provide/foundation/testing/transport/fixtures.py +280 -0
  125. provide/foundation/tools/__init__.py +58 -0
  126. provide/foundation/tools/base.py +348 -0
  127. provide/foundation/tools/cache.py +268 -0
  128. provide/foundation/tools/downloader.py +224 -0
  129. provide/foundation/tools/installer.py +254 -0
  130. provide/foundation/tools/registry.py +223 -0
  131. provide/foundation/tools/resolver.py +321 -0
  132. provide/foundation/tools/verifier.py +186 -0
  133. provide/foundation/tracer/otel.py +7 -11
  134. provide/foundation/tracer/spans.py +2 -2
  135. provide/foundation/transport/__init__.py +155 -0
  136. provide/foundation/transport/base.py +171 -0
  137. provide/foundation/transport/client.py +266 -0
  138. provide/foundation/transport/config.py +140 -0
  139. provide/foundation/transport/errors.py +79 -0
  140. provide/foundation/transport/http.py +232 -0
  141. provide/foundation/transport/middleware.py +360 -0
  142. provide/foundation/transport/registry.py +167 -0
  143. provide/foundation/transport/types.py +45 -0
  144. provide/foundation/utils/deps.py +14 -12
  145. provide/foundation/utils/parsing.py +49 -4
  146. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/METADATA +5 -28
  147. provide_foundation-0.0.0.dev2.dist-info/RECORD +225 -0
  148. provide/foundation/cli/commands/logs/generate_old.py +0 -569
  149. provide/foundation/crypto/certificates.py +0 -896
  150. provide/foundation/logger/emoji/__init__.py +0 -44
  151. provide/foundation/logger/emoji/matrix.py +0 -209
  152. provide/foundation/logger/emoji/sets.py +0 -458
  153. provide/foundation/logger/emoji/types.py +0 -56
  154. provide/foundation/logger/setup/emoji_resolver.py +0 -64
  155. provide_foundation-0.0.0.dev0.dist-info/RECORD +0 -149
  156. /provide/foundation/{observability → integrations}/openobserve/exceptions.py +0 -0
  157. /provide/foundation/{observability → integrations}/openobserve/models.py +0 -0
  158. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/WHEEL +0 -0
  159. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/entry_points.txt +0 -0
  160. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/licenses/LICENSE +0 -0
  161. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,334 @@
1
+ """Archive operation chains and helpers."""
2
+
3
+ import tempfile
4
+ from pathlib import Path
5
+ from typing import Callable
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.file.safe import safe_delete
16
+ from provide.foundation.logger import get_logger
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ @define(slots=True)
22
+ class OperationChain:
23
+ """
24
+ Chain multiple archive operations together.
25
+
26
+ Enables complex operations like tar.gz, tar.bz2, etc.
27
+ Operations are executed in order for creation, reversed for extraction.
28
+ """
29
+
30
+ operations: list[str] = field(factory=list)
31
+
32
+ def execute(self, source: Path, output: Path) -> Path:
33
+ """
34
+ Execute operation chain on source.
35
+
36
+ Args:
37
+ source: Source file or directory
38
+ output: Final output path
39
+
40
+ Returns:
41
+ Path to final output
42
+
43
+ Raises:
44
+ ArchiveError: If any operation fails
45
+ """
46
+ current = source
47
+ temp_files = []
48
+
49
+ try:
50
+ for i, op in enumerate(self.operations):
51
+ # Determine output for this operation
52
+ if i == len(self.operations) - 1:
53
+ # Last operation, use final output
54
+ next_output = output
55
+ else:
56
+ # Intermediate operation, use temp file
57
+ suffix = self._get_suffix_for_operation(op)
58
+ temp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
59
+ temp.close()
60
+ next_output = Path(temp.name)
61
+ temp_files.append(next_output)
62
+
63
+ # Execute operation
64
+ current = self._execute_operation(op, current, next_output)
65
+ logger.debug(f"Executed operation '{op}': {current}")
66
+
67
+ return current
68
+
69
+ except Exception as e:
70
+ raise ArchiveError(f"Operation chain failed: {e}") from e
71
+ finally:
72
+ # Clean up temp files using Foundation's safe file operations
73
+ for temp in temp_files:
74
+ safe_delete(temp, missing_ok=True)
75
+
76
+ def reverse(self, source: Path, output: Path) -> Path:
77
+ """
78
+ Reverse operation chain (extract/decompress).
79
+
80
+ Args:
81
+ source: Source archive
82
+ output: Final output path
83
+
84
+ Returns:
85
+ Path to final output
86
+
87
+ Raises:
88
+ ArchiveError: If any operation fails
89
+ """
90
+ # Reverse the operations and invert them
91
+ reverse_map = {
92
+ 'tar': 'untar',
93
+ 'untar': 'tar',
94
+ 'gzip': 'gunzip',
95
+ 'gunzip': 'gzip',
96
+ 'bzip2': 'bunzip2',
97
+ 'bunzip2': 'bzip2',
98
+ 'zip': 'unzip',
99
+ 'unzip': 'zip',
100
+ }
101
+
102
+ reversed_ops = []
103
+ for op in reversed(self.operations):
104
+ reversed_op = reverse_map.get(op.lower(), op)
105
+ reversed_ops.append(reversed_op)
106
+
107
+ reversed_chain = OperationChain(operations=reversed_ops)
108
+ return reversed_chain.execute(source, output)
109
+
110
+ def _execute_operation(self, operation: str, source: Path, output: Path) -> Path:
111
+ """Execute a single operation."""
112
+ match operation.lower():
113
+ case "tar":
114
+ tar = TarArchive()
115
+ return tar.create(source, output)
116
+ case "untar":
117
+ tar = TarArchive()
118
+ return tar.extract(source, output)
119
+ case "gzip":
120
+ gzip = GzipCompressor()
121
+ return gzip.compress_file(source, output)
122
+ case "gunzip":
123
+ gzip = GzipCompressor()
124
+ return gzip.decompress_file(source, output)
125
+ case "bzip2":
126
+ bz2 = Bzip2Compressor()
127
+ return bz2.compress_file(source, output)
128
+ case "bunzip2":
129
+ bz2 = Bzip2Compressor()
130
+ return bz2.decompress_file(source, output)
131
+ case "zip":
132
+ zip_archive = ZipArchive()
133
+ return zip_archive.create(source, output)
134
+ case "unzip":
135
+ zip_archive = ZipArchive()
136
+ return zip_archive.extract(source, output)
137
+ case _:
138
+ raise ArchiveError(f"Unknown operation: {operation}")
139
+
140
+ def _get_suffix_for_operation(self, operation: str) -> str:
141
+ """Get file suffix for operation."""
142
+ suffixes = {
143
+ "tar": ".tar",
144
+ "gzip": ".gz",
145
+ "bzip2": ".bz2",
146
+ "zip": ".zip",
147
+ }
148
+ return suffixes.get(operation.lower(), ".tmp")
149
+
150
+
151
+ class ArchiveOperations:
152
+ """
153
+ Helper class for common archive operation patterns.
154
+
155
+ Provides convenient methods for common archive formats.
156
+ """
157
+
158
+ @staticmethod
159
+ def create_tar_gz(source: Path, output: Path, deterministic: bool = True) -> Path:
160
+ """
161
+ Create .tar.gz archive in one step.
162
+
163
+ Args:
164
+ source: Source file or directory
165
+ output: Output path (should end with .tar.gz)
166
+ deterministic: Create reproducible archive
167
+
168
+ Returns:
169
+ Path to created archive
170
+
171
+ Raises:
172
+ ArchiveError: If creation fails
173
+ """
174
+ ensure_parent_dir(output)
175
+
176
+ # Create temp tar file
177
+ temp_tar = output.with_suffix('.tar')
178
+ try:
179
+ tar = TarArchive(deterministic=deterministic)
180
+ tar.create(source, temp_tar)
181
+
182
+ # Compress to final output
183
+ gzip = GzipCompressor()
184
+ return gzip.compress_file(temp_tar, output)
185
+ finally:
186
+ # Clean up temp file
187
+ if temp_tar.exists():
188
+ temp_tar.unlink()
189
+
190
+ @staticmethod
191
+ def extract_tar_gz(archive: Path, output: Path) -> Path:
192
+ """
193
+ Extract .tar.gz archive in one step.
194
+
195
+ Args:
196
+ archive: Archive path
197
+ output: Output directory
198
+
199
+ Returns:
200
+ Path to extraction directory
201
+
202
+ Raises:
203
+ ArchiveError: If extraction fails
204
+ """
205
+ output.mkdir(parents=True, exist_ok=True)
206
+
207
+ # Decompress to temp file
208
+ with tempfile.NamedTemporaryFile(suffix='.tar', delete=False) as temp:
209
+ temp_tar = Path(temp.name)
210
+
211
+ try:
212
+ gzip = GzipCompressor()
213
+ gzip.decompress_file(archive, temp_tar)
214
+
215
+ # Extract tar
216
+ tar = TarArchive()
217
+ return tar.extract(temp_tar, output)
218
+ finally:
219
+ # Clean up temp file
220
+ if temp_tar.exists():
221
+ temp_tar.unlink()
222
+
223
+ @staticmethod
224
+ def create_tar_bz2(source: Path, output: Path, deterministic: bool = True) -> Path:
225
+ """
226
+ Create .tar.bz2 archive in one step.
227
+
228
+ Args:
229
+ source: Source file or directory
230
+ output: Output path (should end with .tar.bz2)
231
+ deterministic: Create reproducible archive
232
+
233
+ Returns:
234
+ Path to created archive
235
+
236
+ Raises:
237
+ ArchiveError: If creation fails
238
+ """
239
+ ensure_parent_dir(output)
240
+
241
+ # Create temp tar file
242
+ temp_tar = output.with_suffix('.tar')
243
+ try:
244
+ tar = TarArchive(deterministic=deterministic)
245
+ tar.create(source, temp_tar)
246
+
247
+ # Compress to final output
248
+ bz2 = Bzip2Compressor()
249
+ return bz2.compress_file(temp_tar, output)
250
+ finally:
251
+ # Clean up temp file
252
+ if temp_tar.exists():
253
+ temp_tar.unlink()
254
+
255
+ @staticmethod
256
+ def extract_tar_bz2(archive: Path, output: Path) -> Path:
257
+ """
258
+ Extract .tar.bz2 archive in one step.
259
+
260
+ Args:
261
+ archive: Archive path
262
+ output: Output directory
263
+
264
+ Returns:
265
+ Path to extraction directory
266
+
267
+ Raises:
268
+ ArchiveError: If extraction fails
269
+ """
270
+ output.mkdir(parents=True, exist_ok=True)
271
+
272
+ # Decompress to temp file
273
+ with tempfile.NamedTemporaryFile(suffix='.tar', delete=False) as temp:
274
+ temp_tar = Path(temp.name)
275
+
276
+ try:
277
+ bz2 = Bzip2Compressor()
278
+ bz2.decompress_file(archive, temp_tar)
279
+
280
+ # Extract tar
281
+ tar = TarArchive()
282
+ return tar.extract(temp_tar, output)
283
+ finally:
284
+ # Clean up temp file
285
+ if temp_tar.exists():
286
+ temp_tar.unlink()
287
+
288
+ @staticmethod
289
+ def detect_format(file: Path) -> list[str]:
290
+ """
291
+ Detect archive format and return operation chain.
292
+
293
+ Args:
294
+ file: File path to analyze
295
+
296
+ Returns:
297
+ List of operations needed to extract
298
+
299
+ Raises:
300
+ ArchiveError: If format cannot be detected
301
+ """
302
+ name = file.name.lower()
303
+
304
+ # Check by extension
305
+ if name.endswith('.tar.gz') or name.endswith('.tgz'):
306
+ return ['gunzip', 'untar']
307
+ elif name.endswith('.tar.bz2') or name.endswith('.tbz2'):
308
+ return ['bunzip2', 'untar']
309
+ elif name.endswith('.tar'):
310
+ return ['untar']
311
+ elif name.endswith('.gz'):
312
+ return ['gunzip']
313
+ elif name.endswith('.bz2'):
314
+ return ['bunzip2']
315
+ elif name.endswith('.zip'):
316
+ return ['unzip']
317
+
318
+ # Check by magic numbers
319
+ try:
320
+ with open(file, 'rb') as f:
321
+ magic = f.read(4)
322
+
323
+ if magic[:2] == b'\x1f\x8b': # gzip
324
+ return ['gunzip']
325
+ elif magic[:3] == b'BZh': # bzip2
326
+ return ['bunzip2']
327
+ elif magic[:4] == b'PK\x03\x04': # zip
328
+ return ['unzip']
329
+ elif magic[:3] == b'ustar': # tar (at offset 257)
330
+ return ['untar']
331
+ except Exception:
332
+ pass
333
+
334
+ 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