cfbpy 0.0.2__tar.gz → 1.0.0__tar.gz

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.
cfbpy-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: cfbpy
3
+ Version: 1.0.0
4
+ Summary: Python module for compressing/decompressing compound file binary format (CFB)
5
+ Author: killerwhalee
6
+ Author-email: killerwhalee <killerwhalee@kaist.ac.kr>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.12
16
+ Project-URL: Homepage, https://github.com/killerwhalee/cfbpy
17
+ Project-URL: Issues, https://github.com/killerwhalee/cfbpy/issues
18
+ Description-Content-Type: text/markdown
19
+
20
+ # cfbpy
21
+
22
+ `cfbpy` is a Python package that allows you to compress and decompress compound file binary formats,
23
+ similar to how zip/unzip works for regular zip files.
24
+ It provides an easy-to-use interface for handling compound files through the CompoundFile class.
25
+
26
+ ## Features
27
+
28
+ - Compress a directory into a compound file format.
29
+ - Decompress a compound file back into a directory.
30
+
31
+ ## Installation
32
+
33
+ cfbpy is written in pure Python and has no dependencies. You can install cfbpy via pip:
34
+
35
+ ```bash
36
+ pip install cfbpy
37
+ ```
38
+
39
+ Or add it to a [uv](https://docs.astral.sh/uv/) project:
40
+
41
+ ```bash
42
+ uv add cfbpy
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ Here’s how you can use `cfbpy` to compress and decompress files:
48
+
49
+ ### Compress a Directory
50
+
51
+ ```python
52
+ from cfbpy import compress
53
+
54
+ compress('path/to/directory', 'path/to/output.cfb')
55
+ ```
56
+
57
+ ### Decompress a Compound File
58
+
59
+ ```python
60
+ from cfbpy import decompress
61
+
62
+ decompress('path/to/input.cfb', 'path/to/extracted_directory')
63
+ ```
64
+
65
+ The same functions are also available as `CompoundFile.compress` and `CompoundFile.decompress`.
66
+
67
+ ## Methods
68
+
69
+ - `compress(src, dest=None)`: Compresses the contents of the `src` directory into a compound file at `dest` (default: `<src>.cfb`).
70
+ - `decompress(src, dest=None)`: Decompresses the compound file from `src` to the directory `dest` (default: `src` without its extension).
71
+
72
+ Compound file names are limited by the format, so `compress` raises `ValueError` when a file or directory name
73
+
74
+ - is longer than 31 UTF-16 code units,
75
+ - contains any of `/ \ : !`, or
76
+ - differs from a sibling only in letter case.
77
+
78
+ `decompress` raises `CompoundFileError` (a subclass of `ValueError`) if the input is not a valid compound file, and `ValueError` for entry names that would escape the destination directory, such as `..`.
79
+
80
+ Files are written as a major version 3 compound file (512-byte sectors). Both major version 3 and 4 (4096-byte sectors) files can be decompressed.
81
+
82
+ ## Development
83
+
84
+ The project is managed with [uv](https://docs.astral.sh/uv/).
85
+
86
+ ```bash
87
+ uv sync # create .venv with the package and dev dependencies
88
+ uv run pytest # run tests
89
+ uv run python benchmarks/bench.py # measure compress/decompress time and memory
90
+ uv build # build sdist and wheel into dist/
91
+ ```
92
+
93
+ ## License
94
+
95
+ This project is licensed under the MIT License - see the LICENSE file for details.
96
+
97
+ ## Contributing
98
+
99
+ Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change.
cfbpy-1.0.0/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # cfbpy
2
+
3
+ `cfbpy` is a Python package that allows you to compress and decompress compound file binary formats,
4
+ similar to how zip/unzip works for regular zip files.
5
+ It provides an easy-to-use interface for handling compound files through the CompoundFile class.
6
+
7
+ ## Features
8
+
9
+ - Compress a directory into a compound file format.
10
+ - Decompress a compound file back into a directory.
11
+
12
+ ## Installation
13
+
14
+ cfbpy is written in pure Python and has no dependencies. You can install cfbpy via pip:
15
+
16
+ ```bash
17
+ pip install cfbpy
18
+ ```
19
+
20
+ Or add it to a [uv](https://docs.astral.sh/uv/) project:
21
+
22
+ ```bash
23
+ uv add cfbpy
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ Here’s how you can use `cfbpy` to compress and decompress files:
29
+
30
+ ### Compress a Directory
31
+
32
+ ```python
33
+ from cfbpy import compress
34
+
35
+ compress('path/to/directory', 'path/to/output.cfb')
36
+ ```
37
+
38
+ ### Decompress a Compound File
39
+
40
+ ```python
41
+ from cfbpy import decompress
42
+
43
+ decompress('path/to/input.cfb', 'path/to/extracted_directory')
44
+ ```
45
+
46
+ The same functions are also available as `CompoundFile.compress` and `CompoundFile.decompress`.
47
+
48
+ ## Methods
49
+
50
+ - `compress(src, dest=None)`: Compresses the contents of the `src` directory into a compound file at `dest` (default: `<src>.cfb`).
51
+ - `decompress(src, dest=None)`: Decompresses the compound file from `src` to the directory `dest` (default: `src` without its extension).
52
+
53
+ Compound file names are limited by the format, so `compress` raises `ValueError` when a file or directory name
54
+
55
+ - is longer than 31 UTF-16 code units,
56
+ - contains any of `/ \ : !`, or
57
+ - differs from a sibling only in letter case.
58
+
59
+ `decompress` raises `CompoundFileError` (a subclass of `ValueError`) if the input is not a valid compound file, and `ValueError` for entry names that would escape the destination directory, such as `..`.
60
+
61
+ Files are written as a major version 3 compound file (512-byte sectors). Both major version 3 and 4 (4096-byte sectors) files can be decompressed.
62
+
63
+ ## Development
64
+
65
+ The project is managed with [uv](https://docs.astral.sh/uv/).
66
+
67
+ ```bash
68
+ uv sync # create .venv with the package and dev dependencies
69
+ uv run pytest # run tests
70
+ uv run python benchmarks/bench.py # measure compress/decompress time and memory
71
+ uv build # build sdist and wheel into dist/
72
+ ```
73
+
74
+ ## License
75
+
76
+ This project is licensed under the MIT License - see the LICENSE file for details.
77
+
78
+ ## Contributing
79
+
80
+ Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change.
@@ -0,0 +1,377 @@
1
+ import array, os, secrets, shutil, sys
2
+
3
+ from . import directory, reader
4
+ from .constants import *
5
+ from .reader import CompoundFileError
6
+
7
+ SECTOR_SIZE = 1 << SECTOR_SHIFT
8
+ MINI_SECTOR_SIZE = 1 << MINI_SECTOR_SHIFT
9
+ ENTRIES_PER_SECTOR = SECTOR_SIZE // 4 # FAT/MINIFAT/DIFAT entries per sector
10
+
11
+ # Chunk size for copying large streams
12
+ COPY_CHUNK_SIZE = 1 << 20
13
+
14
+
15
+ def _pack_sectors(entries) -> bytes:
16
+ """
17
+ Pack 32-bit sector numbers as little-endian, padded with FREESECT to a whole sector.
18
+
19
+ """
20
+
21
+ table = array.array(UINT32, entries)
22
+ table.extend([FREESECT] * (-len(table) % ENTRIES_PER_SECTOR))
23
+
24
+ if sys.byteorder == "big":
25
+ table.byteswap()
26
+
27
+ return table.tobytes()
28
+
29
+
30
+ def _extend_chain(table: array.array, count: int) -> int:
31
+ """
32
+ Allocate `count` consecutive sectors at the end of `table` as one chain.
33
+
34
+ Returns first sector number of the chain, or ENDOFCHAIN if `count` is zero.
35
+
36
+ """
37
+
38
+ if not count:
39
+ return ENDOFCHAIN
40
+
41
+ start = len(table)
42
+ table.extend(range(start + 1, start + count))
43
+ table.append(ENDOFCHAIN)
44
+
45
+ return start
46
+
47
+
48
+ class _MiniStream:
49
+ """
50
+ Mini stream and its MINIFAT, kept in memory as every stream is smaller than the cutoff size.
51
+
52
+ """
53
+
54
+ def __init__(self) -> None:
55
+ self.data = bytearray()
56
+ self.fat = array.array(UINT32)
57
+
58
+ def write(self, data: bytes) -> int:
59
+ """
60
+ Append data to the mini stream and return its first mini sector number.
61
+
62
+ """
63
+
64
+ count = -(-len(data) // MINI_SECTOR_SIZE)
65
+ self.data += data
66
+ self.data += bytes(count * MINI_SECTOR_SIZE - len(data))
67
+
68
+ return _extend_chain(self.fat, count)
69
+
70
+
71
+ class _SectorWriter:
72
+ """
73
+ Write sectors sequentially to a file object while building the FAT.
74
+
75
+ Every stream is written as one run of consecutive sectors, so FAT chains are simple ranges and stream data never has to be held in memory as a whole.
76
+
77
+ """
78
+
79
+ def __init__(self, fp) -> None:
80
+ self.fp = fp
81
+ self.fat = array.array(UINT32)
82
+
83
+ # Reserve space for the header, which is written by `close()`
84
+ fp.write(bytes(HEADER_SIZE))
85
+
86
+ def _pad(self, size: int) -> int:
87
+ """
88
+ Pad data written so far to a whole sector and return the number of sectors used.
89
+
90
+ """
91
+
92
+ count = -(-size // SECTOR_SIZE)
93
+ self.fp.write(bytes(count * SECTOR_SIZE - size))
94
+
95
+ return count
96
+
97
+ def write(self, data: bytes) -> int:
98
+ """
99
+ Write data to new sectors and return its first sector number.
100
+
101
+ """
102
+
103
+ self.fp.write(data)
104
+
105
+ return _extend_chain(self.fat, self._pad(len(data)))
106
+
107
+ def copy(self, head: bytes, src) -> tuple[int, int]:
108
+ """
109
+ Write `head` followed by the rest of file object `src` to new sectors.
110
+
111
+ Returns first sector number and size of the stream.
112
+
113
+ """
114
+
115
+ self.fp.write(head)
116
+ size = len(head)
117
+
118
+ while chunk := src.read(COPY_CHUNK_SIZE):
119
+ self.fp.write(chunk)
120
+ size += len(chunk)
121
+
122
+ return _extend_chain(self.fat, self._pad(size)), size
123
+
124
+ def close(self, **header) -> None:
125
+ """
126
+ Write FAT and DIFAT sectors, then the header.
127
+
128
+ """
129
+
130
+ num_data_sectors = len(self.fat)
131
+
132
+ # FAT and DIFAT sectors need FAT entries themselves, so grow both until they fit
133
+ num_fat_sectors = num_difat_sectors = 0
134
+ while True:
135
+ total = num_data_sectors + num_fat_sectors + num_difat_sectors
136
+ fat_needed = -(-total // ENTRIES_PER_SECTOR)
137
+ difat_needed = -(
138
+ -max(0, fat_needed - NUM_HEADER_DIFAT) // (ENTRIES_PER_SECTOR - 1)
139
+ )
140
+
141
+ if (fat_needed, difat_needed) == (num_fat_sectors, num_difat_sectors):
142
+ break
143
+
144
+ num_fat_sectors, num_difat_sectors = fat_needed, difat_needed
145
+
146
+ first_fat_sector = num_data_sectors
147
+ first_difat_sector = first_fat_sector + num_fat_sectors
148
+ fat_sectors = list(range(first_fat_sector, first_difat_sector))
149
+
150
+ # Write FAT, marking FAT and DIFAT sectors
151
+ self.fat.extend([FATSECT] * num_fat_sectors)
152
+ self.fat.extend([DIFSECT] * num_difat_sectors)
153
+ self.fp.write(_pack_sectors(self.fat))
154
+
155
+ # Write DIFAT sectors for FAT sectors that do not fit in the header
156
+ overflow = fat_sectors[NUM_HEADER_DIFAT:]
157
+ per_sector = ENTRIES_PER_SECTOR - 1
158
+
159
+ for index in range(num_difat_sectors):
160
+ entries = overflow[index * per_sector : (index + 1) * per_sector]
161
+ entries.extend([FREESECT] * (per_sector - len(entries)))
162
+
163
+ if index + 1 < num_difat_sectors:
164
+ entries.append(first_difat_sector + index + 1)
165
+ else:
166
+ entries.append(ENDOFCHAIN)
167
+
168
+ self.fp.write(_pack_sectors(entries))
169
+
170
+ header_difat = fat_sectors[:NUM_HEADER_DIFAT]
171
+ header_difat.extend([FREESECT] * (NUM_HEADER_DIFAT - len(header_difat)))
172
+
173
+ # Write header
174
+ self.fp.seek(0)
175
+ self.fp.write(
176
+ HEADER_FORMAT.pack(
177
+ HEADER_SIGNATURE,
178
+ bytes(16), # Header CLSID
179
+ MINOR_VERSION,
180
+ MAJOR_VERSION,
181
+ BYTE_ORDER,
182
+ SECTOR_SHIFT,
183
+ MINI_SECTOR_SHIFT,
184
+ 0, # Number of directory sectors, must be zero for major version 3
185
+ num_fat_sectors,
186
+ header["first_dir_sector"],
187
+ 0, # Transaction signature number
188
+ MINI_STREAM_CUTOFF_SIZE,
189
+ header["first_mini_fat_sector"],
190
+ header["num_mini_fat_sectors"],
191
+ first_difat_sector if num_difat_sectors else ENDOFCHAIN,
192
+ num_difat_sectors,
193
+ *header_difat,
194
+ )
195
+ )
196
+
197
+
198
+ def _walk(src, skip):
199
+ """
200
+ Yield `(parent, name, path)` for every directory and file under `src`.
201
+
202
+ `parent` is the relative path of the parent directory (empty for `src` itself) and `path` is `None` for directories. Entries are sorted so output is deterministic. Files whose absolute path is in `skip` (output files inside `src`) are left out.
203
+
204
+ """
205
+
206
+ for dirpath, dirnames, filenames in os.walk(src):
207
+ dirnames.sort()
208
+ filenames.sort()
209
+
210
+ rel = os.path.relpath(dirpath, src)
211
+ parent = "" if rel == os.curdir else rel
212
+
213
+ if parent:
214
+ yield os.path.dirname(parent), os.path.basename(parent), None
215
+
216
+ for name in filenames:
217
+ path = os.path.join(dirpath, name)
218
+
219
+ if os.path.abspath(path) not in skip:
220
+ yield parent, name, path
221
+
222
+
223
+ def compress(src, dest=None):
224
+ """
225
+ Create compound file from given directory tree.
226
+
227
+ """
228
+
229
+ src = os.path.normpath(src)
230
+
231
+ if not os.path.isdir(src):
232
+ raise NotADirectoryError(f"Source is not a directory: {src}")
233
+
234
+ if not dest:
235
+ dest = f"{src}.cfb"
236
+
237
+ dest = os.path.abspath(dest)
238
+
239
+ # Write to a temporary file next to `dest` and replace `dest` only on success, so a
240
+ # failure never truncates or removes an existing file
241
+ while True:
242
+ temp = os.path.join(
243
+ os.path.dirname(dest),
244
+ f".{os.path.basename(dest)}.{secrets.token_hex(4)}.tmp",
245
+ )
246
+
247
+ try:
248
+ fp = open(temp, "xb")
249
+ break
250
+
251
+ except FileExistsError:
252
+ continue
253
+
254
+ try:
255
+ with fp:
256
+ _compress(src, fp, skip={dest, temp})
257
+
258
+ # Keep permissions of a file being replaced
259
+ if os.path.exists(dest):
260
+ shutil.copymode(dest, temp)
261
+
262
+ os.replace(temp, dest)
263
+
264
+ except BaseException:
265
+ os.remove(temp)
266
+ raise
267
+
268
+
269
+ def _compress(src, fp, skip):
270
+ root = directory.Entry("Root Entry", obj_type=directory.OBJTY_ROOT)
271
+ storages = {"": root}
272
+ mini_stream = _MiniStream()
273
+
274
+ writer = _SectorWriter(fp)
275
+
276
+ for parent, name, path in _walk(src, skip):
277
+ storage = storages[parent]
278
+
279
+ # Add storage as directory entry
280
+ if path is None:
281
+ storages[os.path.join(parent, name)] = storage.add(
282
+ directory.Entry(name, obj_type=directory.OBJTY_STORAGE),
283
+ )
284
+ continue
285
+
286
+ entry = storage.add(directory.Entry(name, obj_type=directory.OBJTY_STREAM))
287
+
288
+ # Write data into mini-sector if smaller than cutoff, otherwise into sector
289
+ with open(path, "rb") as f:
290
+ head = f.read(MINI_STREAM_CUTOFF_SIZE)
291
+
292
+ if len(head) < MINI_STREAM_CUTOFF_SIZE:
293
+ entry.sector, entry.size = mini_stream.write(head), len(head)
294
+
295
+ else:
296
+ entry.sector, entry.size = writer.copy(head, f)
297
+
298
+ # Write mini stream into sector; its location is stored in root entry
299
+ root.sector = writer.write(mini_stream.data)
300
+ root.size = len(mini_stream.data)
301
+
302
+ # Write mini-fat into sector
303
+ mini_fat_data = _pack_sectors(mini_stream.fat) if mini_stream.fat else b""
304
+ first_mini_fat_sector = writer.write(mini_fat_data)
305
+
306
+ # Write directory entry into sector
307
+ first_dir_sector = writer.write(directory.serialize(root, SECTOR_SIZE))
308
+
309
+ # Write fat, difat and header
310
+ writer.close(
311
+ first_dir_sector=first_dir_sector,
312
+ first_mini_fat_sector=first_mini_fat_sector,
313
+ num_mini_fat_sectors=len(mini_fat_data) // SECTOR_SIZE,
314
+ )
315
+
316
+
317
+ def _safe_name(name):
318
+ """
319
+ Return entry name as a file name, rejecting names that would escape the destination directory.
320
+
321
+ """
322
+
323
+ if (
324
+ name in ("", os.curdir, os.pardir)
325
+ or os.sep in name
326
+ or (os.altsep and os.altsep in name)
327
+ or "\0" in name
328
+ or os.path.isabs(name)
329
+ or os.path.splitdrive(name)[0]
330
+ ):
331
+ raise ValueError(f"Unsafe entry name in compound file: {name!r}")
332
+
333
+ return name
334
+
335
+
336
+ def decompress(src, dest=None):
337
+ """
338
+ Decompress compound file and create directory at destination.
339
+
340
+ Raises `CompoundFileError` if `src` is not a valid compound file.
341
+
342
+ """
343
+
344
+ # Set path same as `src` if dest is not set
345
+ if not dest:
346
+ dest, _ = os.path.splitext(src)
347
+
348
+ # Open before creating `dest`, so an invalid file leaves nothing behind
349
+ with reader.CompoundFileReader(src) as cfb:
350
+ os.makedirs(dest, exist_ok=True)
351
+
352
+ # Storages are yielded before their children
353
+ for parts, entry in cfb.walk():
354
+ path = os.path.join(dest, *parts[:-1], _safe_name(parts[-1]))
355
+
356
+ if entry.obj_type == directory.OBJTY_STORAGE:
357
+ os.makedirs(path, exist_ok=True)
358
+
359
+ else:
360
+ with open(path, "wb") as f:
361
+ cfb.copy_stream(entry, f)
362
+
363
+ # Return True if decompressed successfully
364
+ return True
365
+
366
+
367
+ class CompoundFile:
368
+ compress = staticmethod(compress)
369
+ decompress = staticmethod(decompress)
370
+
371
+ def open(self, src):
372
+ """
373
+ Open compound file and create CompoundFile instance.
374
+
375
+ """
376
+
377
+ raise NotImplementedError
@@ -0,0 +1,24 @@
1
+ import array, struct
2
+
3
+ # Header constants
4
+ HEADER_SIGNATURE = b"\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1"
5
+ MINOR_VERSION = 0x003E
6
+ MAJOR_VERSION = 0x0003
7
+ BYTE_ORDER = 0xFFFE
8
+ SECTOR_SHIFT = 0x0009 # 512-byte sectors for major version 3
9
+ MINI_SECTOR_SHIFT = 0x0006
10
+ MINI_STREAM_CUTOFF_SIZE = 0x00001000
11
+
12
+ HEADER_FORMAT = struct.Struct("<8s16sHHHHH6xIIIIIIIII109I")
13
+ HEADER_SIZE = HEADER_FORMAT.size # 512 bytes
14
+ NUM_HEADER_DIFAT = 109
15
+
16
+ # Sector constants
17
+ MAXREGSECT = 0xFFFFFFFA #: (-6) maximum SECT
18
+ DIFSECT = 0xFFFFFFFC #: (-4) denotes a DIFAT sector in a FAT
19
+ FATSECT = 0xFFFFFFFD #: (-3) denotes a FAT sector in a FAT
20
+ ENDOFCHAIN = 0xFFFFFFFE #: (-2) end of a virtual stream chain
21
+ FREESECT = 0xFFFFFFFF #: (-1) unallocated sector
22
+
23
+ # Array typecode of unsigned 32-bit integer, used for FAT and MINIFAT
24
+ UINT32 = next(code for code in "IL" if array.array(code).itemsize == 4)