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.
- provide/foundation/__init__.py +41 -23
- provide/foundation/archive/__init__.py +23 -0
- provide/foundation/archive/base.py +70 -0
- provide/foundation/archive/bzip2.py +157 -0
- provide/foundation/archive/gzip.py +159 -0
- provide/foundation/archive/operations.py +334 -0
- provide/foundation/archive/tar.py +164 -0
- provide/foundation/archive/zip.py +203 -0
- provide/foundation/cli/__init__.py +2 -2
- provide/foundation/cli/commands/deps.py +13 -7
- provide/foundation/cli/commands/logs/__init__.py +1 -1
- provide/foundation/cli/commands/logs/query.py +1 -1
- provide/foundation/cli/commands/logs/send.py +1 -1
- provide/foundation/cli/commands/logs/tail.py +1 -1
- provide/foundation/cli/decorators.py +11 -10
- provide/foundation/cli/main.py +1 -1
- provide/foundation/cli/testing.py +2 -35
- provide/foundation/cli/utils.py +21 -17
- provide/foundation/config/__init__.py +35 -2
- provide/foundation/config/base.py +2 -2
- provide/foundation/config/converters.py +479 -0
- provide/foundation/config/defaults.py +67 -0
- provide/foundation/config/env.py +4 -19
- provide/foundation/config/loader.py +9 -3
- provide/foundation/config/sync.py +19 -4
- provide/foundation/console/input.py +5 -5
- provide/foundation/console/output.py +35 -13
- provide/foundation/context/__init__.py +8 -4
- provide/foundation/context/core.py +85 -109
- provide/foundation/core.py +1 -2
- provide/foundation/crypto/__init__.py +2 -0
- provide/foundation/crypto/certificates/__init__.py +34 -0
- provide/foundation/crypto/certificates/base.py +173 -0
- provide/foundation/crypto/certificates/certificate.py +290 -0
- provide/foundation/crypto/certificates/factory.py +213 -0
- provide/foundation/crypto/certificates/generator.py +138 -0
- provide/foundation/crypto/certificates/loader.py +130 -0
- provide/foundation/crypto/certificates/operations.py +198 -0
- provide/foundation/crypto/certificates/trust.py +107 -0
- provide/foundation/errors/__init__.py +2 -3
- provide/foundation/errors/decorators.py +0 -231
- provide/foundation/errors/types.py +0 -97
- provide/foundation/eventsets/__init__.py +0 -0
- provide/foundation/eventsets/display.py +84 -0
- provide/foundation/eventsets/registry.py +160 -0
- provide/foundation/eventsets/resolver.py +192 -0
- provide/foundation/eventsets/sets/das.py +128 -0
- provide/foundation/eventsets/sets/database.py +125 -0
- provide/foundation/eventsets/sets/http.py +153 -0
- provide/foundation/eventsets/sets/llm.py +139 -0
- provide/foundation/eventsets/sets/task_queue.py +107 -0
- provide/foundation/eventsets/types.py +70 -0
- provide/foundation/file/directory.py +13 -22
- provide/foundation/file/lock.py +3 -1
- provide/foundation/hub/components.py +77 -515
- provide/foundation/hub/config.py +151 -0
- provide/foundation/hub/discovery.py +62 -0
- provide/foundation/hub/handlers.py +81 -0
- provide/foundation/hub/lifecycle.py +194 -0
- provide/foundation/hub/manager.py +4 -4
- provide/foundation/hub/processors.py +44 -0
- provide/foundation/integrations/__init__.py +11 -0
- provide/foundation/{observability → integrations}/openobserve/__init__.py +10 -7
- provide/foundation/{observability → integrations}/openobserve/auth.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/client.py +12 -12
- provide/foundation/{observability → integrations}/openobserve/commands.py +3 -3
- provide/foundation/integrations/openobserve/config.py +37 -0
- provide/foundation/{observability → integrations}/openobserve/formatters.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/otlp.py +1 -1
- provide/foundation/{observability → integrations}/openobserve/search.py +2 -2
- provide/foundation/{observability → integrations}/openobserve/streaming.py +4 -4
- provide/foundation/logger/__init__.py +3 -10
- provide/foundation/logger/config/logging.py +68 -298
- provide/foundation/logger/config/telemetry.py +41 -121
- provide/foundation/logger/core.py +0 -2
- provide/foundation/logger/custom_processors.py +1 -0
- provide/foundation/logger/factories.py +11 -2
- provide/foundation/logger/processors/main.py +20 -84
- provide/foundation/logger/setup/__init__.py +5 -1
- provide/foundation/logger/setup/coordinator.py +76 -24
- provide/foundation/logger/setup/processors.py +2 -9
- provide/foundation/logger/trace.py +27 -0
- provide/foundation/metrics/otel.py +10 -10
- provide/foundation/observability/__init__.py +2 -2
- provide/foundation/process/__init__.py +9 -0
- provide/foundation/process/exit.py +47 -0
- provide/foundation/process/lifecycle.py +115 -59
- provide/foundation/resilience/__init__.py +35 -0
- provide/foundation/resilience/circuit.py +164 -0
- provide/foundation/resilience/decorators.py +220 -0
- provide/foundation/resilience/fallback.py +193 -0
- provide/foundation/resilience/retry.py +325 -0
- provide/foundation/streams/config.py +79 -0
- provide/foundation/streams/console.py +7 -8
- provide/foundation/streams/core.py +6 -3
- provide/foundation/streams/file.py +12 -2
- provide/foundation/testing/__init__.py +84 -2
- provide/foundation/testing/archive/__init__.py +24 -0
- provide/foundation/testing/archive/fixtures.py +217 -0
- provide/foundation/testing/cli.py +30 -17
- provide/foundation/testing/common/__init__.py +32 -0
- provide/foundation/testing/common/fixtures.py +236 -0
- provide/foundation/testing/file/__init__.py +40 -0
- provide/foundation/testing/file/content_fixtures.py +316 -0
- provide/foundation/testing/file/directory_fixtures.py +107 -0
- provide/foundation/testing/file/fixtures.py +52 -0
- provide/foundation/testing/file/special_fixtures.py +153 -0
- provide/foundation/testing/logger.py +117 -11
- provide/foundation/testing/mocking/__init__.py +46 -0
- provide/foundation/testing/mocking/fixtures.py +331 -0
- provide/foundation/testing/process/__init__.py +48 -0
- provide/foundation/testing/process/async_fixtures.py +405 -0
- provide/foundation/testing/process/fixtures.py +56 -0
- provide/foundation/testing/process/subprocess_fixtures.py +209 -0
- provide/foundation/testing/threading/__init__.py +38 -0
- provide/foundation/testing/threading/basic_fixtures.py +101 -0
- provide/foundation/testing/threading/data_fixtures.py +99 -0
- provide/foundation/testing/threading/execution_fixtures.py +263 -0
- provide/foundation/testing/threading/fixtures.py +54 -0
- provide/foundation/testing/threading/sync_fixtures.py +97 -0
- provide/foundation/testing/time/__init__.py +32 -0
- provide/foundation/testing/time/fixtures.py +409 -0
- provide/foundation/testing/transport/__init__.py +30 -0
- provide/foundation/testing/transport/fixtures.py +280 -0
- provide/foundation/tools/__init__.py +58 -0
- provide/foundation/tools/base.py +348 -0
- provide/foundation/tools/cache.py +268 -0
- provide/foundation/tools/downloader.py +224 -0
- provide/foundation/tools/installer.py +254 -0
- provide/foundation/tools/registry.py +223 -0
- provide/foundation/tools/resolver.py +321 -0
- provide/foundation/tools/verifier.py +186 -0
- provide/foundation/tracer/otel.py +7 -11
- provide/foundation/tracer/spans.py +2 -2
- provide/foundation/transport/__init__.py +155 -0
- provide/foundation/transport/base.py +171 -0
- provide/foundation/transport/client.py +266 -0
- provide/foundation/transport/config.py +140 -0
- provide/foundation/transport/errors.py +79 -0
- provide/foundation/transport/http.py +232 -0
- provide/foundation/transport/middleware.py +360 -0
- provide/foundation/transport/registry.py +167 -0
- provide/foundation/transport/types.py +45 -0
- provide/foundation/utils/deps.py +14 -12
- provide/foundation/utils/parsing.py +49 -4
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/METADATA +5 -28
- provide_foundation-0.0.0.dev2.dist-info/RECORD +225 -0
- provide/foundation/cli/commands/logs/generate_old.py +0 -569
- provide/foundation/crypto/certificates.py +0 -896
- provide/foundation/logger/emoji/__init__.py +0 -44
- provide/foundation/logger/emoji/matrix.py +0 -209
- provide/foundation/logger/emoji/sets.py +0 -458
- provide/foundation/logger/emoji/types.py +0 -56
- provide/foundation/logger/setup/emoji_resolver.py +0 -64
- provide_foundation-0.0.0.dev0.dist-info/RECORD +0 -149
- /provide/foundation/{observability → integrations}/openobserve/exceptions.py +0 -0
- /provide/foundation/{observability → integrations}/openobserve/models.py +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/WHEEL +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/entry_points.txt +0 -0
- {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev2.dist-info}/licenses/LICENSE +0 -0
- {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
|