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 +107 -0
- splitzip/__main__.py +153 -0
- splitzip/exceptions.py +45 -0
- splitzip/structures.py +386 -0
- splitzip/utils.py +183 -0
- splitzip/volume.py +268 -0
- splitzip/writer.py +554 -0
- splitzip-0.1.0.dist-info/METADATA +228 -0
- splitzip-0.1.0.dist-info/RECORD +12 -0
- splitzip-0.1.0.dist-info/WHEEL +4 -0
- splitzip-0.1.0.dist-info/entry_points.txt +2 -0
- splitzip-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|