Skip to content

Commit 38992bc

Browse files
authored
Merge pull request #2327 from gitpython-developers/fix/stream-copy-short-reads
fix/stream copy short reads
2 parents 44ad498 + eb613ec commit 38992bc

2 files changed

Lines changed: 44 additions & 1 deletion

File tree

‎git/util.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ def stream_copy(source: SupportsRead[AnyStr], destination: SupportsWrite[AnyStr]
219219
"""Copy all data from the `source` stream into the `destination` stream in chunks
220220
of size `chunk_size`.
221221
222+
Keep reading after short reads until the source returns an empty chunk.
223+
222224
:return:
223225
Number of bytes written
224226
"""
@@ -227,7 +229,7 @@ def stream_copy(source: SupportsRead[AnyStr], destination: SupportsWrite[AnyStr]
227229
chunk = source.read(chunk_size)
228230
destination.write(chunk)
229231
br += len(chunk)
230-
if len(chunk) < chunk_size:
232+
if not chunk:
231233
break
232234
# END reading output stream
233235
return br

‎test/test_util.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import threading
1414
import time
1515
from datetime import datetime
16+
from io import BytesIO, StringIO
1617
from unittest import SkipTest, mock
1718

1819
import ddt
@@ -41,6 +42,7 @@
4142
join_path,
4243
remove_password_if_present,
4344
rmtree,
45+
stream_copy,
4446
)
4547
from test.cleanup import cleanup_directory
4648
from test.lib import TestBase, requires_symlinks, with_rw_repo
@@ -67,6 +69,45 @@ def test_join_path_keeps_later_tokens_relative(parts, expected):
6769
assert join_path(*parts) == expected
6870

6971

72+
@pytest.mark.parametrize(
73+
"parts, expected",
74+
[([b"ab", b"c", b"def"], b"abcdef"), (["ab", "c", "def"], "abcdef"), ([], b""), ([], "")],
75+
)
76+
def test_stream_copy_waits_for_an_empty_read(parts, expected):
77+
chunks = iter(parts)
78+
79+
class Reader:
80+
def read(self, size):
81+
chunk = next(chunks, expected[:0])
82+
assert len(chunk) <= size
83+
return chunk
84+
85+
destination = BytesIO() if isinstance(expected, bytes) else StringIO()
86+
assert stream_copy(Reader(), destination, chunk_size=4) == len(expected)
87+
assert destination.getvalue() == expected
88+
89+
90+
@pytest.mark.skipif(sys.platform == "win32", reason="requires POSIX unbuffered pipe reads")
91+
def test_stream_copy_keeps_reading_an_unbuffered_pipe():
92+
read_fd, write_fd = os.pipe()
93+
94+
class Destination(BytesIO):
95+
def write(self, data):
96+
result = super().write(data)
97+
if data and not writer.closed:
98+
# Make the second chunk available only after the first short read is copied.
99+
writer.write(b"second")
100+
writer.close()
101+
return result
102+
103+
destination = Destination()
104+
with os.fdopen(read_fd, "rb", buffering=0) as source, os.fdopen(write_fd, "wb", buffering=0) as writer:
105+
writer.write(b"first-")
106+
copied = stream_copy(source, destination, chunk_size=1024)
107+
assert copied == len(b"first-second")
108+
assert destination.getvalue() == b"first-second"
109+
110+
70111
@pytest.fixture
71112
def tmp_path(request):
72113
# Unreadable files and symlinks cannot be copied by native fixture tooling.

0 commit comments

Comments
 (0)