splitzip 0.1.0__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.
splitzip/__init__.py ADDED
@@ -0,0 +1,107 @@
1
+ """
2
+ splitzip - Create split ZIP archives compatible with standard tools.
3
+
4
+ Create multi-part ZIP archives that can be extracted with Windows Explorer,
5
+ WinZip, 7-Zip, and other standard tools without requiring special software.
6
+
7
+ Example:
8
+ >>> import splitzip
9
+ >>>
10
+ >>> # Simple one-liner
11
+ >>> splitzip.create("backup.zip", ["file1.txt", "data/"], split_size="100MB")
12
+ >>>
13
+ >>> # Context manager for more control
14
+ >>> with splitzip.SplitZipWriter("backup.zip", split_size="100MB") as zf:
15
+ ... zf.write("file1.txt")
16
+ ... zf.write("data/", recursive=True)
17
+ ... zf.writestr("hello.txt", b"Hello, world!")
18
+
19
+ The resulting files will be named:
20
+ backup.z01, backup.z02, ..., backup.zip
21
+
22
+ The final .zip file contains the central directory and must be present
23
+ along with all .zXX files for extraction.
24
+ """
25
+
26
+ from .exceptions import (
27
+ CompressionError,
28
+ FileNotFoundInArchiveError,
29
+ IntegrityError,
30
+ SplitZipError,
31
+ VolumeTooSmallError,
32
+ VolumeError,
33
+ )
34
+ from .structures import Compression
35
+ from .utils import format_size, parse_size
36
+ from .writer import SplitZipWriter
37
+
38
+ __version__ = "0.1.0"
39
+ __all__ = [
40
+ # Main classes
41
+ "SplitZipWriter",
42
+ # Convenience functions
43
+ "create",
44
+ # Constants
45
+ "Compression",
46
+ "STORED",
47
+ "DEFLATED",
48
+ # Utilities
49
+ "parse_size",
50
+ "format_size",
51
+ # Exceptions
52
+ "SplitZipError",
53
+ "VolumeError",
54
+ "VolumeTooSmallError",
55
+ "CompressionError",
56
+ "IntegrityError",
57
+ "FileNotFoundInArchiveError",
58
+ ]
59
+
60
+ # Convenience aliases
61
+ STORED = Compression.STORED
62
+ DEFLATED = Compression.DEFLATED
63
+
64
+
65
+ def create(
66
+ path: str,
67
+ files: list[str],
68
+ split_size: int | str,
69
+ compression: int = DEFLATED,
70
+ compresslevel: int = 6,
71
+ recursive: bool = True,
72
+ ) -> list[str]:
73
+ """
74
+ Create a split ZIP archive from a list of files/directories.
75
+
76
+ This is a convenience function for simple use cases. For more control,
77
+ use SplitZipWriter directly.
78
+
79
+ Args:
80
+ path: Path for the final .zip file.
81
+ files: List of file/directory paths to include.
82
+ split_size: Maximum size per volume (e.g., "100MB", "700MiB", 104857600).
83
+ compression: Compression method (STORED or DEFLATED).
84
+ compresslevel: DEFLATE compression level 1-9 (default 6).
85
+ recursive: If True, add directory contents recursively.
86
+
87
+ Returns:
88
+ List of paths to all volume files created.
89
+
90
+ Example:
91
+ >>> splitzip.create(
92
+ ... "backup.zip",
93
+ ... ["documents/", "photos/", "important.pdf"],
94
+ ... split_size="650MB" # CD-ROM size
95
+ ... )
96
+ ['backup.z01', 'backup.z02', 'backup.zip']
97
+ """
98
+ with SplitZipWriter(
99
+ path,
100
+ split_size=split_size,
101
+ compression=compression,
102
+ compresslevel=compresslevel,
103
+ ) as zf:
104
+ for file_path in files:
105
+ zf.write(file_path, recursive=recursive)
106
+
107
+ return [str(p) for p in zf.volume_paths]
splitzip/__main__.py ADDED
@@ -0,0 +1,153 @@
1
+ """Command-line interface for splitzip."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import SplitZipWriter, __version__, format_size, parse_size
10
+ from .structures import Compression
11
+
12
+
13
+ def progress_callback(filename: str, done: int, total: int) -> None:
14
+ """Print progress for current file."""
15
+ if total > 0:
16
+ pct = (done / total) * 100
17
+ bar_width = 30
18
+ filled = int(bar_width * done / total)
19
+ bar = "█" * filled + "░" * (bar_width - filled)
20
+ print(f"\r {bar} {pct:5.1f}% {Path(filename).name}", end="", flush=True)
21
+ if done >= total:
22
+ print()
23
+
24
+
25
+ def volume_callback(volume_num: int, path: Path) -> None:
26
+ """Print when a new volume is created."""
27
+ print(f" Created: {path}")
28
+
29
+
30
+ def cmd_create(args: argparse.Namespace) -> int:
31
+ """Handle the create command."""
32
+ output = Path(args.output)
33
+ files = [Path(f) for f in args.files]
34
+
35
+ # Validate inputs
36
+ for f in files:
37
+ if not f.exists():
38
+ print(f"Error: '{f}' does not exist", file=sys.stderr)
39
+ return 1
40
+
41
+ try:
42
+ split_size = parse_size(args.split_size)
43
+ except ValueError as e:
44
+ print(f"Error: {e}", file=sys.stderr)
45
+ return 1
46
+
47
+ compression = Compression.STORED if args.store else Compression.DEFLATED
48
+
49
+ print(f"Creating split archive: {output}")
50
+ print(f" Split size: {format_size(split_size)}")
51
+ print(f" Compression: {'STORED' if args.store else f'DEFLATE level {args.level}'}")
52
+ print()
53
+
54
+ try:
55
+ with SplitZipWriter(
56
+ output,
57
+ split_size=split_size,
58
+ compression=compression,
59
+ compresslevel=args.level,
60
+ on_volume=volume_callback if args.verbose else None,
61
+ on_progress=progress_callback if args.verbose else None,
62
+ ) as zf:
63
+ for file_path in files:
64
+ if args.verbose:
65
+ print(f"Adding: {file_path}")
66
+ zf.write(file_path, recursive=not args.no_recursive)
67
+
68
+ print()
69
+ print(f"Created {len(zf.volume_paths)} volume(s):")
70
+ for p in zf.volume_paths:
71
+ size = p.stat().st_size
72
+ print(f" {p.name}: {format_size(size)}")
73
+
74
+ except Exception as e:
75
+ print(f"Error: {e}", file=sys.stderr)
76
+ return 1
77
+
78
+ return 0
79
+
80
+
81
+ def main(argv: list[str] | None = None) -> int:
82
+ """Main entry point for CLI."""
83
+ parser = argparse.ArgumentParser(
84
+ prog="splitzip",
85
+ description="Create split ZIP archives compatible with standard tools.",
86
+ )
87
+ parser.add_argument(
88
+ "--version",
89
+ action="version",
90
+ version=f"%(prog)s {__version__}",
91
+ )
92
+
93
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
94
+
95
+ # Create command
96
+ create_parser = subparsers.add_parser(
97
+ "create",
98
+ help="Create a split ZIP archive",
99
+ description="Create a split ZIP archive from files and directories.",
100
+ )
101
+ create_parser.add_argument(
102
+ "-o", "--output",
103
+ required=True,
104
+ help="Output archive path (e.g., backup.zip)",
105
+ )
106
+ create_parser.add_argument(
107
+ "-s", "--split-size",
108
+ required=True,
109
+ help="Maximum size per volume (e.g., 100MB, 700MiB, 4.7GB)",
110
+ )
111
+ create_parser.add_argument(
112
+ "files",
113
+ nargs="+",
114
+ help="Files and directories to add",
115
+ )
116
+ create_parser.add_argument(
117
+ "-0", "--store",
118
+ action="store_true",
119
+ help="Store without compression",
120
+ )
121
+ create_parser.add_argument(
122
+ "-l", "--level",
123
+ type=int,
124
+ default=6,
125
+ choices=range(1, 10),
126
+ metavar="1-9",
127
+ help="Compression level (default: 6)",
128
+ )
129
+ create_parser.add_argument(
130
+ "--no-recursive",
131
+ action="store_true",
132
+ help="Don't add directory contents recursively",
133
+ )
134
+ create_parser.add_argument(
135
+ "-v", "--verbose",
136
+ action="store_true",
137
+ help="Show progress and volume creation",
138
+ )
139
+
140
+ args = parser.parse_args(argv)
141
+
142
+ if args.command is None:
143
+ parser.print_help()
144
+ return 0
145
+
146
+ if args.command == "create":
147
+ return cmd_create(args)
148
+
149
+ return 0
150
+
151
+
152
+ if __name__ == "__main__":
153
+ sys.exit(main())
splitzip/exceptions.py ADDED
@@ -0,0 +1,45 @@
1
+ """Custom exceptions for splitzip."""
2
+
3
+
4
+ class SplitZipError(Exception):
5
+ """Base exception for all splitzip errors."""
6
+
7
+
8
+ class VolumeError(SplitZipError):
9
+ """Error related to volume management."""
10
+
11
+
12
+ class VolumeTooSmallError(VolumeError):
13
+ """Split size is too small to fit required headers."""
14
+
15
+ def __init__(self, split_size: int, min_required: int) -> None:
16
+ self.split_size = split_size
17
+ self.min_required = min_required
18
+ super().__init__(
19
+ f"Split size {split_size} bytes is too small. "
20
+ f"Minimum required: {min_required} bytes"
21
+ )
22
+
23
+
24
+ class CompressionError(SplitZipError):
25
+ """Error during compression."""
26
+
27
+
28
+ class IntegrityError(SplitZipError):
29
+ """CRC mismatch or corrupted data."""
30
+
31
+ def __init__(self, expected: int, actual: int, filename: str) -> None:
32
+ self.expected = expected
33
+ self.actual = actual
34
+ self.filename = filename
35
+ super().__init__(
36
+ f"CRC32 mismatch for '{filename}': expected {expected:08x}, got {actual:08x}"
37
+ )
38
+
39
+
40
+ class FileNotFoundInArchiveError(SplitZipError):
41
+ """Requested file not found in archive."""
42
+
43
+ def __init__(self, filename: str) -> None:
44
+ self.filename = filename
45
+ super().__init__(f"File not found in archive: '{filename}'")
splitzip/structures.py ADDED
@@ -0,0 +1,386 @@
1
+ """ZIP file format data structures.
2
+
3
+ Based on PKWARE's APPNOTE.TXT specification.
4
+ https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import struct
10
+ from dataclasses import dataclass, field
11
+ from enum import IntEnum
12
+ from typing import ClassVar
13
+
14
+
15
+ class Compression(IntEnum):
16
+ """Compression methods supported by ZIP."""
17
+
18
+ STORED = 0 # No compression
19
+ DEFLATED = 8 # DEFLATE compression
20
+
21
+
22
+ class GeneralPurposeFlag(IntEnum):
23
+ """General purpose bit flags."""
24
+
25
+ ENCRYPTED = 1 << 0
26
+ # Bits 1-2: compression options (for DEFLATE: 0=normal, 1=max, 2=fast, 3=super fast)
27
+ DATA_DESCRIPTOR = 1 << 3 # CRC and sizes in data descriptor after file data
28
+ ENHANCED_DEFLATE = 1 << 4
29
+ COMPRESSED_PATCHED = 1 << 5
30
+ STRONG_ENCRYPTION = 1 << 6
31
+ UTF8 = 1 << 11 # Filename and comment are UTF-8 encoded
32
+ ENHANCED_COMPRESSION = 1 << 12
33
+ MASKED_HEADERS = 1 << 13
34
+
35
+
36
+ # Signatures
37
+ LOCAL_FILE_HEADER_SIG = 0x04034B50
38
+ CENTRAL_DIR_HEADER_SIG = 0x02014B50
39
+ END_OF_CENTRAL_DIR_SIG = 0x06054B50
40
+ DATA_DESCRIPTOR_SIG = 0x08074B50
41
+ SPLIT_ARCHIVE_SIG = 0x08074B50 # Same as data descriptor
42
+
43
+
44
+ @dataclass
45
+ class LocalFileHeader:
46
+ """Local file header structure (precedes each file's data)."""
47
+
48
+ SIGNATURE: ClassVar[int] = LOCAL_FILE_HEADER_SIG
49
+ STRUCT_FORMAT: ClassVar[str] = "<IHHHHHIIIHH"
50
+ FIXED_SIZE: ClassVar[int] = 30 # Size without filename and extra
51
+
52
+ version_needed: int = 20 # 2.0 for DEFLATE
53
+ flags: int = 0
54
+ compression: int = Compression.DEFLATED
55
+ mod_time: int = 0
56
+ mod_date: int = 0
57
+ crc32: int = 0
58
+ compressed_size: int = 0
59
+ uncompressed_size: int = 0
60
+ filename: bytes = b""
61
+ extra: bytes = b""
62
+
63
+ def to_bytes(self) -> bytes:
64
+ """Serialize to bytes."""
65
+ header = struct.pack(
66
+ self.STRUCT_FORMAT,
67
+ self.SIGNATURE,
68
+ self.version_needed,
69
+ self.flags,
70
+ self.compression,
71
+ self.mod_time,
72
+ self.mod_date,
73
+ self.crc32,
74
+ self.compressed_size,
75
+ self.uncompressed_size,
76
+ len(self.filename),
77
+ len(self.extra),
78
+ )
79
+ return header + self.filename + self.extra
80
+
81
+ @classmethod
82
+ def from_bytes(cls, data: bytes) -> "LocalFileHeader":
83
+ """Deserialize from bytes."""
84
+ if len(data) < cls.FIXED_SIZE:
85
+ raise ValueError(f"Data too short for LocalFileHeader: {len(data)} < {cls.FIXED_SIZE}")
86
+
87
+ (
88
+ sig,
89
+ version_needed,
90
+ flags,
91
+ compression,
92
+ mod_time,
93
+ mod_date,
94
+ crc32,
95
+ compressed_size,
96
+ uncompressed_size,
97
+ filename_len,
98
+ extra_len,
99
+ ) = struct.unpack(cls.STRUCT_FORMAT, data[: cls.FIXED_SIZE])
100
+
101
+ if sig != cls.SIGNATURE:
102
+ raise ValueError(f"Invalid local file header signature: {sig:#010x}")
103
+
104
+ filename = data[cls.FIXED_SIZE : cls.FIXED_SIZE + filename_len]
105
+ extra = data[cls.FIXED_SIZE + filename_len : cls.FIXED_SIZE + filename_len + extra_len]
106
+
107
+ return cls(
108
+ version_needed=version_needed,
109
+ flags=flags,
110
+ compression=compression,
111
+ mod_time=mod_time,
112
+ mod_date=mod_date,
113
+ crc32=crc32,
114
+ compressed_size=compressed_size,
115
+ uncompressed_size=uncompressed_size,
116
+ filename=filename,
117
+ extra=extra,
118
+ )
119
+
120
+ @property
121
+ def total_size(self) -> int:
122
+ """Total size of header including variable fields."""
123
+ return self.FIXED_SIZE + len(self.filename) + len(self.extra)
124
+
125
+
126
+ @dataclass
127
+ class DataDescriptor:
128
+ """Optional data descriptor (follows file data when sizes unknown upfront)."""
129
+
130
+ SIGNATURE: ClassVar[int] = DATA_DESCRIPTOR_SIG
131
+ STRUCT_FORMAT_WITH_SIG: ClassVar[str] = "<IIII"
132
+ STRUCT_FORMAT_NO_SIG: ClassVar[str] = "<III"
133
+ SIZE_WITH_SIG: ClassVar[int] = 16
134
+ SIZE_NO_SIG: ClassVar[int] = 12
135
+
136
+ crc32: int = 0
137
+ compressed_size: int = 0
138
+ uncompressed_size: int = 0
139
+
140
+ def to_bytes(self, include_signature: bool = True) -> bytes:
141
+ """Serialize to bytes."""
142
+ if include_signature:
143
+ return struct.pack(
144
+ self.STRUCT_FORMAT_WITH_SIG,
145
+ self.SIGNATURE,
146
+ self.crc32,
147
+ self.compressed_size,
148
+ self.uncompressed_size,
149
+ )
150
+ else:
151
+ return struct.pack(
152
+ self.STRUCT_FORMAT_NO_SIG,
153
+ self.crc32,
154
+ self.compressed_size,
155
+ self.uncompressed_size,
156
+ )
157
+
158
+
159
+ @dataclass
160
+ class CentralDirectoryHeader:
161
+ """Central directory file header."""
162
+
163
+ SIGNATURE: ClassVar[int] = CENTRAL_DIR_HEADER_SIG
164
+ STRUCT_FORMAT: ClassVar[str] = "<IHHHHHHIIIHHHHHII"
165
+ FIXED_SIZE: ClassVar[int] = 46
166
+
167
+ version_made_by: int = 20 # 2.0, MS-DOS compatible
168
+ version_needed: int = 20
169
+ flags: int = 0
170
+ compression: int = Compression.DEFLATED
171
+ mod_time: int = 0
172
+ mod_date: int = 0
173
+ crc32: int = 0
174
+ compressed_size: int = 0
175
+ uncompressed_size: int = 0
176
+ disk_number_start: int = 0 # Disk where file starts
177
+ internal_attr: int = 0
178
+ external_attr: int = 0
179
+ local_header_offset: int = 0 # Offset of local header on starting disk
180
+ filename: bytes = b""
181
+ extra: bytes = b""
182
+ comment: bytes = b""
183
+
184
+ def to_bytes(self) -> bytes:
185
+ """Serialize to bytes."""
186
+ header = struct.pack(
187
+ self.STRUCT_FORMAT,
188
+ self.SIGNATURE,
189
+ self.version_made_by,
190
+ self.version_needed,
191
+ self.flags,
192
+ self.compression,
193
+ self.mod_time,
194
+ self.mod_date,
195
+ self.crc32,
196
+ self.compressed_size,
197
+ self.uncompressed_size,
198
+ len(self.filename),
199
+ len(self.extra),
200
+ len(self.comment),
201
+ self.disk_number_start,
202
+ self.internal_attr,
203
+ self.external_attr,
204
+ self.local_header_offset,
205
+ )
206
+ return header + self.filename + self.extra + self.comment
207
+
208
+ @classmethod
209
+ def from_bytes(cls, data: bytes) -> "CentralDirectoryHeader":
210
+ """Deserialize from bytes."""
211
+ if len(data) < cls.FIXED_SIZE:
212
+ raise ValueError(
213
+ f"Data too short for CentralDirectoryHeader: {len(data)} < {cls.FIXED_SIZE}"
214
+ )
215
+
216
+ (
217
+ sig,
218
+ version_made_by,
219
+ version_needed,
220
+ flags,
221
+ compression,
222
+ mod_time,
223
+ mod_date,
224
+ crc32,
225
+ compressed_size,
226
+ uncompressed_size,
227
+ filename_len,
228
+ extra_len,
229
+ comment_len,
230
+ disk_number_start,
231
+ internal_attr,
232
+ external_attr,
233
+ local_header_offset,
234
+ ) = struct.unpack(cls.STRUCT_FORMAT, data[: cls.FIXED_SIZE])
235
+
236
+ if sig != cls.SIGNATURE:
237
+ raise ValueError(f"Invalid central directory header signature: {sig:#010x}")
238
+
239
+ offset = cls.FIXED_SIZE
240
+ filename = data[offset : offset + filename_len]
241
+ offset += filename_len
242
+ extra = data[offset : offset + extra_len]
243
+ offset += extra_len
244
+ comment = data[offset : offset + comment_len]
245
+
246
+ return cls(
247
+ version_made_by=version_made_by,
248
+ version_needed=version_needed,
249
+ flags=flags,
250
+ compression=compression,
251
+ mod_time=mod_time,
252
+ mod_date=mod_date,
253
+ crc32=crc32,
254
+ compressed_size=compressed_size,
255
+ uncompressed_size=uncompressed_size,
256
+ disk_number_start=disk_number_start,
257
+ internal_attr=internal_attr,
258
+ external_attr=external_attr,
259
+ local_header_offset=local_header_offset,
260
+ filename=filename,
261
+ extra=extra,
262
+ comment=comment,
263
+ )
264
+
265
+ @property
266
+ def total_size(self) -> int:
267
+ """Total size of header including variable fields."""
268
+ return self.FIXED_SIZE + len(self.filename) + len(self.extra) + len(self.comment)
269
+
270
+
271
+ @dataclass
272
+ class EndOfCentralDirectory:
273
+ """End of central directory record."""
274
+
275
+ SIGNATURE: ClassVar[int] = END_OF_CENTRAL_DIR_SIG
276
+ STRUCT_FORMAT: ClassVar[str] = "<IHHHHIIH"
277
+ FIXED_SIZE: ClassVar[int] = 22
278
+
279
+ disk_number: int = 0 # Number of this disk
280
+ disk_with_cd_start: int = 0 # Disk where central directory starts
281
+ entries_on_disk: int = 0 # Entries in central directory on this disk
282
+ total_entries: int = 0 # Total entries in central directory
283
+ cd_size: int = 0 # Size of central directory
284
+ cd_offset: int = 0 # Offset of central directory on starting disk
285
+ comment: bytes = b""
286
+
287
+ def to_bytes(self) -> bytes:
288
+ """Serialize to bytes."""
289
+ return struct.pack(
290
+ self.STRUCT_FORMAT,
291
+ self.SIGNATURE,
292
+ self.disk_number,
293
+ self.disk_with_cd_start,
294
+ self.entries_on_disk,
295
+ self.total_entries,
296
+ self.cd_size,
297
+ self.cd_offset,
298
+ len(self.comment),
299
+ ) + self.comment
300
+
301
+ @classmethod
302
+ def from_bytes(cls, data: bytes) -> "EndOfCentralDirectory":
303
+ """Deserialize from bytes."""
304
+ if len(data) < cls.FIXED_SIZE:
305
+ raise ValueError(
306
+ f"Data too short for EndOfCentralDirectory: {len(data)} < {cls.FIXED_SIZE}"
307
+ )
308
+
309
+ (
310
+ sig,
311
+ disk_number,
312
+ disk_with_cd_start,
313
+ entries_on_disk,
314
+ total_entries,
315
+ cd_size,
316
+ cd_offset,
317
+ comment_len,
318
+ ) = struct.unpack(cls.STRUCT_FORMAT, data[: cls.FIXED_SIZE])
319
+
320
+ if sig != cls.SIGNATURE:
321
+ raise ValueError(f"Invalid end of central directory signature: {sig:#010x}")
322
+
323
+ comment = data[cls.FIXED_SIZE : cls.FIXED_SIZE + comment_len]
324
+
325
+ return cls(
326
+ disk_number=disk_number,
327
+ disk_with_cd_start=disk_with_cd_start,
328
+ entries_on_disk=entries_on_disk,
329
+ total_entries=total_entries,
330
+ cd_size=cd_size,
331
+ cd_offset=cd_offset,
332
+ comment=comment,
333
+ )
334
+
335
+ @property
336
+ def total_size(self) -> int:
337
+ """Total size of record including comment."""
338
+ return self.FIXED_SIZE + len(self.comment)
339
+
340
+
341
+ @dataclass
342
+ class ZipEntry:
343
+ """Internal tracking of an entry being written."""
344
+
345
+ filename: str
346
+ arcname: bytes
347
+ compression: int
348
+ mod_time: int
349
+ mod_date: int
350
+ crc32: int = 0
351
+ compressed_size: int = 0
352
+ uncompressed_size: int = 0
353
+ disk_number_start: int = 0
354
+ local_header_offset: int = 0
355
+ external_attr: int = 0
356
+ extra: bytes = b""
357
+ comment: bytes = b""
358
+
359
+ def to_central_directory_header(self) -> CentralDirectoryHeader:
360
+ """Convert to a CentralDirectoryHeader."""
361
+ return CentralDirectoryHeader(
362
+ version_made_by=20,
363
+ version_needed=20,
364
+ flags=GeneralPurposeFlag.UTF8,
365
+ compression=self.compression,
366
+ mod_time=self.mod_time,
367
+ mod_date=self.mod_date,
368
+ crc32=self.crc32,
369
+ compressed_size=self.compressed_size,
370
+ uncompressed_size=self.uncompressed_size,
371
+ disk_number_start=self.disk_number_start,
372
+ internal_attr=0,
373
+ external_attr=self.external_attr,
374
+ local_header_offset=self.local_header_offset,
375
+ filename=self.arcname,
376
+ extra=self.extra,
377
+ comment=self.comment,
378
+ )
379
+
380
+ def _is_utf8(self) -> bool:
381
+ """Check if filename requires UTF-8 flag."""
382
+ try:
383
+ self.arcname.decode("ascii")
384
+ return False
385
+ except UnicodeDecodeError:
386
+ return True