Skip to content

Commit 65fa074

Browse files
committed
Prevent file objects unexpectedly being closed by boto after uploading to s3. Fixes #153
1 parent 8f79c0b commit 65fa074

File tree

2 files changed

+31
-1
lines changed

2 files changed

+31
-1
lines changed

tests/storage/test_storage.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,22 @@ def test_fetch_file_contents_using_s3_select_raises_errors(
702702
)
703703
assert len(file_contents) == 0
704704

705+
def test_non_closeable_buffered_reader_does_not_close_underlying_file(self):
706+
# deliberately not using a context manager to
707+
# open/read the file so we can correctly observe
708+
# the closing behavior.
709+
tmp = tempfile.TemporaryFile(mode="w+b")
710+
tmp.write(b"data")
711+
tmp.seek(0)
712+
try:
713+
wrapped = storage.NonCloseableBufferedReader(tmp)
714+
wrapped.close()
715+
716+
# Should have flushed, but NOT closed
717+
assert not tmp.closed
718+
finally:
719+
tmp.close()
720+
705721

706722
class TestMemoryFileStore:
707723
def setup_method(self):

xocto/storage/storage.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,15 @@ class ReadableBinaryFile(Protocol):
125125
def read(self, size: int = ...) -> bytes: ...
126126

127127

128+
class NonCloseableBufferedReader(io.BufferedReader):
129+
def __init__(self, raw: ReadableBinaryFile, *args: Any, **kwargs: Any):
130+
super().__init__(cast(io.RawIOBase, raw), *args, **kwargs)
131+
132+
def close(self) -> None:
133+
# Don't close the underlying file object, just flush the buffer.
134+
self.flush()
135+
136+
128137
class StreamingBodyIOAdapter(io.RawIOBase):
129138
"""
130139
Wrapper to adapt a boto3 S3 object to a standard Python "file-like" object
@@ -465,7 +474,7 @@ def store_file(
465474
_log_existing_file_returned(key_path)
466475
return self.bucket_name, existing_boto_object.key
467476

468-
readable = _to_stream(contents=contents)
477+
readable = NonCloseableBufferedReader(_to_stream(contents=contents))
469478

470479
# `boto_client.upload_fileobj` is type annotated with `Fileobj: BinaryIO`. However, in
471480
# practice the only file-like method it needs is `read(size=...)`. This cast allows us to
@@ -489,6 +498,11 @@ def store_file(
489498
ExtraArgs=extra_args,
490499
)
491500

501+
# we call readable.detach instead of file_obj.detach because mypy
502+
# assumes file_obj is a BinaryIO and not a BufferedReader thus
503+
# doesn't have a detach method.
504+
readable.detach()
505+
492506
return self.bucket_name, key_path
493507

494508
def store_versioned_file(

0 commit comments

Comments
 (0)