Skip to content

Commit 34fef3c

Browse files
authored
Merge pull request #176 from uktrade/feat/better-buffering
feat: remove input buffering and flush output buffer after local header
2 parents 921d2db + 1854301 commit 34fef3c

4 files changed

Lines changed: 90 additions & 12 deletions

File tree

docs/api/functions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def stream_zip(
3333
| Name | Type | Description
3434
| --------------------| -------------------------------| ------------------------------------------
3535
| files | Iterable[MemberFile] | The member files of the ZIP
36-
| chunk_size | int | How many bytes of each member file to fetch before compressing
36+
| chunk_size | int | Maximum `bytes` instance length yielded to client code
3737
| get_compressobj | Callable[[], 'zlib._Compress'] | A function returning a [Python zlib compression object](https://docs.python.org/3/library/zlib.html#zlib.compressobj) |
3838
| password | Optional[str] | The password used to encrypt all the member files with AES-256 encryption adhering to the Winzip AE-2 specification - see [Password protection](/get-started/password-protection/)
3939
| extended_timestamps | bool | Whether to save extended timestamps in the ZIP file
@@ -82,7 +82,7 @@ async def async_stream_zip(
8282
| Name | Type | Description
8383
| --------------------| -------------------------------| ------------------------------------------
8484
| files | AsyncIterable[AsyncMemberFile] | The member files of the ZIP
85-
| chunk_size | int | How many bytes of each member file to fetch before compressing
85+
| chunk_size | int | Maximum `bytes` instance length yielded to client code
8686
| get_compressobj | Callable[[], 'zlib._Compress'] | A function returning a [Python zlib compression object](https://docs.python.org/3/library/zlib.html#zlib.compressobj) |
8787
| password | Optional[str] | The password used to encrypt all the member files with AES-256 encryption adhering to the Winzip AE-2 specification - see [Password protection](/get-started/password-protection/)
8888
| extended_timestamps | bool | Whether to save extended timestamps in the ZIP file

docs/get-started/advanced-usage.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,16 @@ If you wish to disable compression entirely for these methods, you can pass `lev
2323

2424
## Custom chunk size
2525

26-
The default `bytes` instance size is 65536 bytes. To customise this, you can override the `chunk_size` parameter.
26+
The default `bytes` instance size for output is 65536 bytes. To customise this, you can override the `chunk_size` parameter.
2727

2828
```python
2929
for zipped_chunk in stream_zip(unzipped_files(), chunk_size=65536):
3030
print(zipped_chunk)
3131
```
3232

33-
This one size is used both for input - splitting or gathering any uncompressed data into `chunk_size` bytes before attempting to compress it, and in output - splitting or gathering any compressed data into `chunk_size` bytes before returning it to client code.
33+
This size is used to buffer output - splitting or gathering any compressed data into a `chunk_size` buffer before yielding it to client code. There may be performance differences with different `chunk_size` values; the default chunk_size may not be optimal for your use case.
3434

35-
There may be performance differences with different `chunk_size` values. The default chunk_size may not be optimal for your use case.
35+
The buffer is flushed just before iterating over the bytes of each member file, irrespective of `chunk_size`.
3636

3737

3838
## Extended timestamps

stream_zip/__init__.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ def _get(self, offset: int, default_get_compressobj: _CompressObjGetter) -> _Met
9696

9797
return _ZIP_AUTO_TYPE_INNER()
9898

99+
# Sentinal object as a command that output buffer be flushed
100+
# Extends from bytes to pass type checking
101+
class _Flusher(bytes):
102+
pass
103+
104+
_flush = _Flusher()
105+
99106

100107
###############################
101108
# Public sentinel objects/types
@@ -135,6 +142,9 @@ def up_to(num: int) -> Iterable[bytes]:
135142
break
136143
else:
137144
offset = 0
145+
if chunk is _flush:
146+
chunk = b''
147+
break
138148
to_yield = min(num, len(chunk) - offset)
139149
offset = offset + to_yield
140150
num -= to_yield
@@ -280,6 +290,7 @@ def _zip_64_local_header_and_data(
280290
))
281291
yield from _(name_encoded)
282292
yield from _(extra)
293+
yield _flush
283294

284295
uncompressed_size, raw_compressed_size, crc_32 = yield from encryption_func(_zip_data(
285296
chunks,
@@ -347,6 +358,7 @@ def _zip_32_local_header_and_data(
347358
))
348359
yield from _(name_encoded)
349360
yield from _(extra)
361+
yield _flush
350362

351363
uncompressed_size, raw_compressed_size, crc_32 = yield from encryption_func(_zip_data(
352364
chunks,
@@ -444,6 +456,7 @@ def _no_compression_64_local_header_and_data(
444456
))
445457
yield from _(name_encoded)
446458
yield from _(extra)
459+
yield _flush
447460

448461
yield from encryption_func((chunk for chunk in chunks))
449462

@@ -506,6 +519,7 @@ def _no_compression_32_local_header_and_data(
506519
))
507520
yield from _(name_encoded)
508521
yield from _(extra)
522+
yield _flush
509523

510524
yield from encryption_func((chunk for chunk in chunks))
511525

@@ -582,6 +596,7 @@ def _no_compression_streamed_64_local_header_and_data(
582596
))
583597
yield from _(name_encoded)
584598
yield from _(extra)
599+
yield _flush
585600

586601
yield from encryption_func(_no_compression_streamed_data(chunks, uncompressed_size, crc_32, 0xffffffffffffffff))
587602

@@ -642,6 +657,7 @@ def _no_compression_streamed_32_local_header_and_data(
642657
))
643658
yield from _(name_encoded)
644659
yield from _(extra)
660+
yield _flush
645661

646662
yield from encryption_func(_no_compression_streamed_data(chunks, uncompressed_size, crc_32, 0xffffffff))
647663

@@ -716,7 +732,7 @@ def _no_compression_streamed_data(chunks: Iterable[bytes], uncompressed_size: in
716732
(99, 28, aes_flag, aes_extra_struct.pack(aes_extra_signature, 7, 2, b'AE', 3, raw_compression), 0, _get_encrypt_aes(password)) if password is not None else \
717733
(raw_compression, 0, 0, b'', 0xffffffff, _encrypt_dummy)
718734

719-
central_directory_header_entry, name_encoded, extra = yield from data_func(compression, aes_size_increase, aes_flags, name_encoded, mod_at_ms_dos, mod_at_unix_extra, aes_extra, external_attr, uncompressed_size, crc_32, crc_32_mask, _get_compress_obj, encryption_func, evenly_sized(chunks))
735+
central_directory_header_entry, name_encoded, extra = yield from data_func(compression, aes_size_increase, aes_flags, name_encoded, mod_at_ms_dos, mod_at_unix_extra, aes_extra, external_attr, uncompressed_size, crc_32, crc_32_mask, _get_compress_obj, encryption_func, chunks)
720736
central_directory_size += len(central_directory_header_signature) + len(central_directory_header_entry) + len(name_encoded) + len(extra)
721737
central_directory.append((central_directory_header_entry, name_encoded, extra))
722738

@@ -786,8 +802,7 @@ def _no_compression_streamed_data(chunks: Iterable[bytes], uncompressed_size: in
786802
0, # ZIP_32 file comment length
787803
))
788804

789-
zipped_chunks = get_zipped_chunks_uneven()
790-
yield from evenly_sized(zipped_chunks)
805+
yield from evenly_sized(get_zipped_chunks_uneven())
791806

792807

793808
async def async_stream_zip(

test_stream_zip.py

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections import Counter
12
from datetime import datetime, timezone, timedelta
23
from io import BytesIO
34
import asyncio
@@ -152,7 +153,7 @@ def files():
152153
yield 'file-1', now, mode, method(1, zlib.crc32(b'')), (b'a',)
153154

154155
with pytest.raises(CRC32IntegrityError):
155-
for name, size, chunks in stream_unzip(stream_zip(files())):
156+
for _ in stream_zip(files()):
156157
pass
157158

158159

@@ -173,7 +174,8 @@ def files():
173174

174175
with pytest.raises(UncompressedSizeIntegrityError):
175176
for name, size, chunks in stream_unzip(stream_zip(files())):
176-
pass
177+
for _ in chunks:
178+
pass
177179

178180

179181
def test_with_stream_unzip_auto_small():
@@ -762,10 +764,71 @@ def get_sizes():
762764
yield len(chunk)
763765

764766
sizes = list(get_sizes())
765-
assert set(sizes[:-1]) == {65536}
767+
assert sizes[0] == 65
768+
assert set(sizes[1:-1]) == {65536}
766769
assert sizes[-1] <= 65536
767770

768771

772+
@pytest.mark.parametrize(
773+
"method",
774+
[
775+
ZIP_32,
776+
ZIP_64,
777+
NO_COMPRESSION_64(1, 2547889144),
778+
NO_COMPRESSION_32(1, 2547889144),
779+
],
780+
)
781+
def test_local_headers_flushed(method):
782+
now = datetime.strptime('2021-01-01 21:01:12', '%Y-%m-%d %H:%M:%S')
783+
mode = stat.S_IFREG | 0o600
784+
785+
state = []
786+
787+
def data():
788+
state.append('data')
789+
yield b'-'
790+
791+
def files():
792+
state.append('file')
793+
yield 'file-1', now, mode, method, data()
794+
795+
for chunk in stream_zip(files()):
796+
state.append('chunk')
797+
if b'file-1' in chunk:
798+
state.append('file-name')
799+
800+
assert state == ['file', 'chunk', 'file-name', 'data', 'chunk', 'file-name']
801+
802+
803+
@pytest.mark.parametrize(
804+
"method",
805+
[
806+
NO_COMPRESSION_64,
807+
NO_COMPRESSION_32,
808+
],
809+
)
810+
def test_local_headers_flushed_buffered_data(method):
811+
now = datetime.strptime('2021-01-01 21:01:12', '%Y-%m-%d %H:%M:%S')
812+
mode = stat.S_IFREG | 0o600
813+
814+
state = []
815+
816+
def data():
817+
state.append('data')
818+
yield b'-'
819+
820+
def files():
821+
state.append('file')
822+
yield 'file-1', now, mode, method, data()
823+
824+
for chunk in stream_zip(files()):
825+
state.append('chunk')
826+
if b'file-1' in chunk:
827+
state.append('file-name')
828+
829+
assert state == ['file', 'data', 'chunk', 'file-name', 'chunk', 'file-name']
830+
831+
769832
@pytest.mark.parametrize(
770833
"method",
771834
[
@@ -1384,7 +1447,7 @@ async def test():
13841447
state.append('out')
13851448

13861449
asyncio.get_event_loop().run_until_complete(test())
1387-
assert state == ['in', 'in', 'out', 'in', 'out', 'in', 'out', 'out']
1450+
assert state == ['out', 'in', 'in', 'out', 'in', 'out', 'in', 'out', 'out']
13881451

13891452

13901453
@pytest.mark.skipif(

0 commit comments

Comments
 (0)