Add a FileBytes class that allows diffing and patching to be done against files that are not fully loaded in RAM.
parent
80c301ff85
commit
7ef541ca0c
6 changed files with 1533 additions and 14 deletions
@ -1,7 +1,9 @@ |
||||
from .binary import BinaryDiffException, BinaryDiff, ByteUtil |
||||
from .filebytes import FileBytes |
||||
|
||||
__all__ = [ |
||||
"BinaryDiffException", |
||||
"BinaryDiff", |
||||
"ByteUtil", |
||||
"FileBytes", |
||||
] |
||||
|
@ -0,0 +1,347 @@ |
||||
from typing import BinaryIO, Dict, List, Set, Tuple, Union, overload |
||||
|
||||
|
||||
class FileBytes: |
||||
def __init__(self, handle: BinaryIO) -> None: |
||||
self.__handle: BinaryIO = handle |
||||
self.__patches: Dict[int, int] = {} |
||||
self.__copies: List["FileBytes"] = [] |
||||
self.__unsafe: bool = False |
||||
|
||||
handle.seek(0, 2) |
||||
self.__filelength: int = handle.tell() |
||||
self.__origfilelength: int = self.__filelength |
||||
self.__patchlength: int = self.__filelength |
||||
|
||||
@property |
||||
def handle(self) -> BinaryIO: |
||||
return self.__handle |
||||
|
||||
def __len__(self) -> int: |
||||
if self.__unsafe: |
||||
raise Exception("Another FileBytes instance representing the same file was written back!") |
||||
return self.__patchlength |
||||
|
||||
def __add__(self, other: object) -> "FileBytes": |
||||
if self.__unsafe: |
||||
raise Exception("Another FileBytes instance representing the same file was written back!") |
||||
if isinstance(other, FileBytes): |
||||
clone = self.clone() |
||||
clone.append(other[:]) |
||||
elif isinstance(other, bytes): |
||||
clone = self.clone() |
||||
clone.append(other) |
||||
else: |
||||
raise NotImplementedError("Not implemented!") |
||||
return clone |
||||
|
||||
def clone(self) -> "FileBytes": |
||||
# Make a safe copy so that in-memory patches can be changed. |
||||
myclone = FileBytes(self.__handle) |
||||
myclone.__patches = {k: v for k, v in self.__patches.items()} |
||||
myclone.__filelength = self.__filelength |
||||
myclone.__patchlength = self.__patchlength |
||||
myclone.__origfilelength = self.__origfilelength |
||||
|
||||
# Make sure we can invalidate copies if we write back the data. |
||||
myclone.__copies.append(self) |
||||
self.__copies.append(myclone) |
||||
|
||||
return myclone |
||||
|
||||
def append(self, data: bytes) -> None: |
||||
if self.__unsafe: |
||||
raise Exception("Another FileBytes instance representing the same file was written back!") |
||||
|
||||
# Add data to the end of our representation. |
||||
for off, change in enumerate(data): |
||||
self.__patches[self.__patchlength + off] = change |
||||
|
||||
self.__patchlength += len(data) |
||||
|
||||
def truncate(self, size: int) -> None: |
||||
if self.__unsafe: |
||||
raise Exception("Another FileBytes instance representing the same file was written back!") |
||||
|
||||
# Truncate the resulting data |
||||
if size < 0: |
||||
raise NotImplementedError("Not implemented!") |
||||
if size >= self.__patchlength: |
||||
# We are already this short? |
||||
return |
||||
|
||||
# Set the file length to this size so we don't read anything past it. |
||||
if size < self.__filelength: |
||||
self.__filelength = size |
||||
|
||||
# Get rid of any changes made in the truncation range. |
||||
for off in range(size, self.__patchlength): |
||||
if off in self.__patches: |
||||
del self.__patches[off] |
||||
|
||||
# Set the length of this object to the size as well so resizing will |
||||
# zero out the data. |
||||
self.__patchlength = size |
||||
|
||||
def __gather(self, already: Set["FileBytes"], need: "FileBytes") -> None: |
||||
for inst in need.__copies: |
||||
if inst not in already: |
||||
already.add(inst) |
||||
self.__gather(already, inst) |
||||
|
||||
def write_changes(self) -> None: |
||||
if self.__unsafe: |
||||
raise Exception("Another FileBytes instance representing the same file was written back!") |
||||
|
||||
locations = sorted(self.__patches.keys()) |
||||
keys: Set[int] = set(locations) |
||||
handled: Set[int] = set() |
||||
|
||||
# First off, see if we need to truncate the file. |
||||
if self.__filelength < self.__origfilelength: |
||||
self.__handle.truncate(self.__filelength) |
||||
self.__origfilelength = self.__filelength |
||||
if self.__filelength > self.__origfilelength: |
||||
raise Exception("Logic error, somehow resized file bigger than it started?") |
||||
|
||||
# Now, gather up any changes to the file and write them back. |
||||
for location in locations: |
||||
if location in handled: |
||||
# Already wrote this in a chunk. |
||||
continue |
||||
|
||||
# Figure out the maximum range for this chunk. |
||||
start = location |
||||
end = location + 1 |
||||
while end in keys: |
||||
end += 1 |
||||
|
||||
# Sum it up |
||||
data = bytes(self.__patches[loc] for loc in range(start, end)) |
||||
|
||||
# Write it |
||||
self.__handle.seek(start) |
||||
self.__handle.write(data) |
||||
|
||||
# Mark it complete |
||||
handled.update(range(start, end)) |
||||
|
||||
if keys != handled: |
||||
raise Exception("Logic error, failed to write some data!") |
||||
|
||||
# Now that we've serialized out the data, clean up our own representation. |
||||
self.__handle.flush() |
||||
self.__patches.clear() |
||||
self.__filelength = self.__patchlength |
||||
|
||||
# Finally, find all other clones of this class and notify them that they're |
||||
# unsafe, so that there isn't any surprise behavior if somebody clones a |
||||
# FileBytes and then writes back to the underlying file on that clone. This |
||||
# is because the only thing we have in memory is the patches we've made, so |
||||
# if the underlying file is changed suddenly its all wrong. |
||||
notify: Set[FileBytes] = {self} |
||||
self.__gather(notify, self) |
||||
for inst in notify: |
||||
if inst is self: |
||||
continue |
||||
|
||||
# Mark this clone as unsafe for read/write operations. |
||||
inst.__unsafe = True |
||||
|
||||
# Set up the clone so that if it is cloned itself, the clone will |
||||
# work since it can read directly from the updated file. |
||||
inst.__filelength = self.__filelength |
||||
inst.__patchlength = self.__patchlength |
||||
inst.__origfilelength = self.__origfilelength |
||||
inst.__patches.clear() |
||||
|
||||
def __slice(self, key: slice) -> Tuple[int, int, int]: |
||||
# Determine step of slice |
||||
if key.step is None: |
||||
step = 1 |
||||
else: |
||||
step = key.step |
||||
|
||||
# Determine start of slice |
||||
if key.start is None: |
||||
start = 0 if step > 0 else self.__patchlength |
||||
elif key.start < 0: |
||||
start = self.__patchlength + key.start |
||||
else: |
||||
start = key.start |
||||
|
||||
# Determine end of slice |
||||
if key.stop is None: |
||||
stop = self.__patchlength if step > 0 else -1 |
||||
elif key.stop < 0: |
||||
stop = self.__patchlength + key.stop |
||||
else: |
||||
stop = key.stop |
||||
|
||||
if start < 0: |
||||
raise Exception("Logic error!") |
||||
if start >= self.__patchlength: |
||||
start = self.__patchlength |
||||
if stop >= self.__patchlength: |
||||
stop = self.__patchlength |
||||
|
||||
return (start, stop, step) |
||||
|
||||
@overload |
||||
def __getitem__(self, key: int) -> int: |
||||
... |
||||
|
||||
@overload |
||||
def __getitem__(self, key: slice) -> bytes: |
||||
... |
||||
|
||||
def __getitem__(self, key: Union[int, slice]) -> Union[int, bytes]: |
||||
if self.__unsafe: |
||||
raise Exception("Another FileBytes instance representing the same file was written back!") |
||||
|
||||
if isinstance(key, int): |
||||
# Support negative indexing. |
||||
if key < 0: |
||||
key = self.__patchlength + key |
||||
|
||||
if key >= self.__patchlength: |
||||
raise IndexError("FileBytes index out of range") |
||||
|
||||
# Look up in our modifications, and then fall back to the file. |
||||
if key in self.__patches: |
||||
return self.__patches[key] |
||||
else: |
||||
if key >= self.__filelength: |
||||
raise Exception("Logic error, should never fall through to loading file bytes in area enlarged by patches!") |
||||
self.__handle.seek(key) |
||||
return self.__handle.read(1)[0] |
||||
|
||||
elif isinstance(key, slice): |
||||
# Grab our iterators. |
||||
start, stop, step = self.__slice(key) |
||||
|
||||
if start == stop: |
||||
return b"" |
||||
if start > stop and step > 0: |
||||
return b"" |
||||
if start < stop and step < 0: |
||||
return b"" |
||||
|
||||
# Do we have any modifications to the file in this area? |
||||
modifications = any(index in self.__patches for index in range(start, stop, step)) |
||||
outofrange = any(index >= self.__filelength for index in range(start, stop, step)) |
||||
|
||||
# Now see if we can do any fast loading |
||||
if start < stop and step == 1: |
||||
if not modifications and not outofrange: |
||||
# This is just a contiguous read |
||||
self.__handle.seek(start) |
||||
return self.__handle.read(stop - start) |
||||
else: |
||||
# We need to modify at least one of the bytes in this read. |
||||
self.__handle.seek(start) |
||||
data = [x for x in self.__handle.read(stop - start)] |
||||
|
||||
# Append any amount of data we need to read past the end of the file. |
||||
if len(data) < stop - start: |
||||
data = data + ([0] * (stop - len(data))) |
||||
|
||||
# Now we have to modify the data with our own overlay. |
||||
for off in range(start, stop): |
||||
if off in self.__patches: |
||||
data[off - start] = self.__patches[off] |
||||
|
||||
return bytes(data) |
||||
elif start > stop and step == -1: |
||||
if not modifications and not outofrange: |
||||
# This is just a continguous read, reversed |
||||
self.__handle.seek(stop + 1) |
||||
return self.__handle.read(start - stop)[::-1] |
||||
else: |
||||
self.__handle.seek(stop + 1) |
||||
data = [x for x in self.__handle.read(start - stop)] |
||||
|
||||
# Append any amount of data we need to read past the end of the file. |
||||
if len(data) < stop - start: |
||||
data = data + ([0] * (stop - len(data))) |
||||
|
||||
# Now we have to modify the data with our own overlay. |
||||
for index, off in enumerate(range(stop + 1, start + 1)): |
||||
if off in self.__patches: |
||||
data[index] = self.__patches[off] |
||||
|
||||
return bytes(data[::-1]) |
||||
else: |
||||
# Gotta load the slow way |
||||
resp: List[bytes] = [] |
||||
for off in range(start, stop, step): |
||||
if off in self.__patches: |
||||
resp.append(bytes([self.__patches[off]])) |
||||
else: |
||||
if off >= self.__filelength: |
||||
raise Exception("Logic error, should never fall through to loading file bytes in area enlarged by patches!") |
||||
self.__handle.seek(off) |
||||
resp.append(self.__handle.read(1)) |
||||
return b"".join(resp) |
||||
|
||||
else: |
||||
raise NotImplementedError("Not implemented!") |
||||
|
||||
@overload |
||||
def __setitem__(self, key: int, val: int) -> None: |
||||
... |
||||
|
||||
@overload |
||||
def __setitem__(self, key: slice, val: bytes) -> None: |
||||
... |
||||
|
||||
def __setitem__(self, key: Union[int, slice], val: Union[int, bytes]) -> None: |
||||
if self.__unsafe: |
||||
raise Exception("Another FileBytes instance representing the same file was written back!") |
||||
|
||||
if isinstance(key, int): |
||||
if not isinstance(val, int): |
||||
raise NotImplementedError("Not implemented!") |
||||
|
||||
# Support negative indexing. |
||||
if key < 0: |
||||
key = self.__patchlength + key |
||||
if key >= self.__patchlength: |
||||
raise IndexError("FileBytes index out of range") |
||||
|
||||
self.__patches[key] = val |
||||
|
||||
elif isinstance(key, slice): |
||||
if not isinstance(val, bytes): |
||||
raise NotImplementedError("Not implemented!") |
||||
|
||||
# Grab our iterators. |
||||
start, stop, step = self.__slice(key) |
||||
vallen = len(val) |
||||
|
||||
if start == stop: |
||||
if vallen != 0: |
||||
raise NotImplementedError("Cannot resize FileBuffer!") |
||||
if start > stop and step > 0: |
||||
if vallen != 0: |
||||
raise NotImplementedError("Cannot resize FileBuffer!") |
||||
if start < stop and step < 0: |
||||
if vallen != 0: |
||||
raise NotImplementedError("Cannot resize FileBuffer!") |
||||
|
||||
# Now, verify the patches are the right length. Make sure that if |
||||
# somebody catches NotImplementedError that we don't partially |
||||
# modify ourselves. |
||||
for index, _off in enumerate(range(start, stop, step)): |
||||
if index >= vallen: |
||||
raise NotImplementedError("Cannot resize FileBuffer!") |
||||
|
||||
if index != (vallen - 1): |
||||
raise NotImplementedError("Cannot resize FileBuffer!") |
||||
|
||||
# Finally, perform the modification. |
||||
for index, off in enumerate(range(start, stop, step)): |
||||
self.__patches[off] = val[index] |
||||
|
||||
else: |
||||
raise NotImplementedError("Not implemented!") |
@ -0,0 +1,714 @@ |
||||
import io |
||||
import unittest |
||||
|
||||
from arcadeutils import FileBytes |
||||
|
||||
|
||||
class TestFileBytes(unittest.TestCase): |
||||
|
||||
def test_read_only_operations(self) -> None: |
||||
b = b"0123456789" |
||||
fb = FileBytes(io.BytesIO(b)) |
||||
|
||||
# Length check. |
||||
self.assertEqual( |
||||
len(fb), |
||||
len(b), |
||||
) |
||||
|
||||
# Basic index lookup. |
||||
self.assertEqual( |
||||
fb[5], |
||||
b[5], |
||||
) |
||||
|
||||
# Make sure negative indexing works. |
||||
self.assertEqual( |
||||
fb[-2], |
||||
b[-2], |
||||
) |
||||
|
||||
# Indexing outside of the length as an individual lookup |
||||
# should cause an IndexError. |
||||
with self.assertRaises(IndexError): |
||||
fb[10] |
||||
|
||||
# Basic start:end lookups. |
||||
self.assertEqual( |
||||
fb[3:7], |
||||
b[3:7], |
||||
) |
||||
|
||||
# Leave out the start or end. |
||||
self.assertEqual( |
||||
fb[3:], |
||||
b[3:], |
||||
) |
||||
self.assertEqual( |
||||
fb[:5], |
||||
b[:5], |
||||
) |
||||
self.assertEqual( |
||||
fb[-2:], |
||||
b[-2:], |
||||
) |
||||
self.assertEqual( |
||||
fb[:-8], |
||||
b[:-8] |
||||
) |
||||
|
||||
# Mixed positive and negative indexes. |
||||
self.assertEqual( |
||||
fb[3:-2], |
||||
b[3:-2], |
||||
) |
||||
self.assertEqual( |
||||
fb[-8:5], |
||||
b[-8:5], |
||||
) |
||||
|
||||
# Resulting in no data. |
||||
self.assertEqual( |
||||
fb[3:3], |
||||
b[3:3], |
||||
) |
||||
self.assertEqual( |
||||
fb[5:3], |
||||
fb[5:3], |
||||
) |
||||
|
||||
# Out of range. |
||||
self.assertEqual( |
||||
fb[5:15], |
||||
b[5:15], |
||||
) |
||||
|
||||
# Copy |
||||
self.assertEqual( |
||||
fb[:], |
||||
b, |
||||
) |
||||
|
||||
# Indexing with a zero step should raise a ValueError. |
||||
with self.assertRaises(ValueError): |
||||
fb[3:5:0] |
||||
|
||||
# Lookups with a step. |
||||
self.assertEqual( |
||||
fb[3:7:2], |
||||
b[3:7:2], |
||||
) |
||||
self.assertEqual( |
||||
fb[7:3:-2], |
||||
b[7:3:-2], |
||||
) |
||||
|
||||
# Reverse copy. |
||||
self.assertEqual( |
||||
fb[::-1], |
||||
b[::-1], |
||||
) |
||||
|
||||
# Provide default explicitly. |
||||
self.assertEqual( |
||||
fb[3:7:1], |
||||
b[3:7:1], |
||||
) |
||||
|
||||
# Negative single step. |
||||
self.assertEqual( |
||||
fb[7:3:-1], |
||||
b[7:3:-1], |
||||
) |
||||
|
||||
# Lookups that result in no data. |
||||
self.assertEqual( |
||||
fb[3:7:-1], |
||||
b[3:7:-1], |
||||
) |
||||
self.assertEqual( |
||||
fb[7:3:1], |
||||
b[7:3:1], |
||||
) |
||||
|
||||
# Make sure that a clone of this object doesn't get any file changes |
||||
# and that it is identical. |
||||
self.assertEqual( |
||||
fb.clone()[:], |
||||
b, |
||||
) |
||||
|
||||
# Attempt to serialize out the data and make sure it did not change |
||||
# before calling write. |
||||
handle = fb.handle |
||||
if not isinstance(handle, io.BytesIO): |
||||
raise Exception("File handle changed type somehow!") |
||||
self.assertEqual( |
||||
handle.getvalue(), |
||||
b, |
||||
) |
||||
|
||||
# Make sure that the data is identical after calling write as well. |
||||
fb.write_changes() |
||||
handle = fb.handle |
||||
if not isinstance(handle, io.BytesIO): |
||||
raise Exception("File handle changed type somehow!") |
||||
self.assertEqual( |
||||
handle.getvalue(), |
||||
b, |
||||
) |
||||
|
||||
def test_read_after_modify(self) -> None: |
||||
b = b"012a456bc9" |
||||
fb = FileBytes(io.BytesIO(b"0123456789")) |
||||
|
||||
# Do some simple modifications. |
||||
fb[3] = 97 |
||||
fb[7:9] = b"bc" |
||||
|
||||
# Length check. |
||||
self.assertEqual( |
||||
len(fb), |
||||
len(b), |
||||
) |
||||
|
||||
# Basic index lookup. |
||||
self.assertEqual( |
||||
fb[5], |
||||
b[5], |
||||
) |
||||
|
||||
# Make sure negative indexing works. |
||||
self.assertEqual( |
||||
fb[-2], |
||||
b[-2], |
||||
) |
||||
|
||||
# Indexing outside of the length as an individual lookup |
||||
# should cause an IndexError. |
||||
with self.assertRaises(IndexError): |
||||
fb[10] |
||||
|
||||
# Basic start:end lookups. |
||||
self.assertEqual( |
||||
fb[3:7], |
||||
b[3:7], |
||||
) |
||||
|
||||
# Leave out the start or end. |
||||
self.assertEqual( |
||||
fb[3:], |
||||
b[3:], |
||||
) |
||||
self.assertEqual( |
||||
fb[:5], |
||||
b[:5], |
||||
) |
||||
self.assertEqual( |
||||
fb[-2:], |
||||
b[-2:], |
||||
) |
||||
self.assertEqual( |
||||
fb[:-8], |
||||
b[:-8] |
||||
) |
||||
|
||||
# Mixed positive and negative indexes. |
||||
self.assertEqual( |
||||
fb[3:-2], |
||||
b[3:-2], |
||||
) |
||||
self.assertEqual( |
||||
fb[-8:5], |
||||
b[-8:5], |
||||
) |
||||
|
||||
# Resulting in no data. |
||||
self.assertEqual( |
||||
fb[3:3], |
||||
b[3:3], |
||||
) |
||||
self.assertEqual( |
||||
fb[5:3], |
||||
fb[5:3], |
||||
) |
||||
|
||||
# Out of range. |
||||
self.assertEqual( |
||||
fb[5:15], |
||||
b[5:15], |
||||
) |
||||
|
||||
# Copy |
||||
self.assertEqual( |
||||
fb[:], |
||||
b, |
||||
) |
||||
|
||||
# Indexing with a zero step should raise a ValueError. |
||||
with self.assertRaises(ValueError): |
||||
fb[3:5:0] |
||||
|
||||
# Lookups with a step. |
||||
self.assertEqual( |
||||
fb[3:7:2], |
||||
b[3:7:2], |
||||
) |
||||
self.assertEqual( |
||||
fb[7:3:-2], |
||||
b[7:3:-2], |
||||
) |
||||
|
||||
# Reverse copy. |
||||
self.assertEqual( |
||||
fb[::-1], |
||||
b[::-1], |
||||
) |
||||
|
||||
# Provide default explicitly. |
||||
self.assertEqual( |
||||
fb[3:7:1], |
||||
b[3:7:1], |
||||
) |
||||
|
||||
# Negative single step. |
||||
self.assertEqual( |
||||
fb[7:3:-1], |
||||
b[7:3:-1], |
||||
) |
||||
|
||||
# Lookups that result in no data. |
||||
self.assertEqual( |
||||
fb[3:7:-1], |
||||
b[3:7:-1], |
||||
) |
||||
self.assertEqual( |
||||
fb[7:3:1], |
||||
b[7:3:1], |
||||
) |
||||
|
||||
# Verify that it gets serialized correctly. |
||||
fb.write_changes() |
||||
handle = fb.handle |
||||
if not isinstance(handle, io.BytesIO): |
||||
raise Exception("File handle changed type somehow!") |
||||
self.assertEqual( |
||||
handle.getvalue(), |
||||
b, |
||||
) |
||||
|
||||
def test_modify_variants(self) -> None: |
||||
fb = FileBytes(io.BytesIO(b"0123456789")) |
||||
|
||||
fb[3] = 97 |
||||
self.assertEqual( |
||||
fb[:], |
||||
b"012a456789", |
||||
) |
||||
|
||||
fb[7:9] = b"bc" |
||||
self.assertEqual( |
||||
fb[:], |
||||
b"012a456bc9", |
||||
) |
||||
|
||||
fb[4:8:2] = b"de" |
||||
self.assertEqual( |
||||
fb[:], |
||||
b"012ad5ebc9", |
||||
) |
||||
fb[-1] = 102 |
||||
self.assertEqual( |
||||
fb[:], |
||||
b"012ad5ebcf", |
||||
) |
||||
|
||||
# Verify that it gets serialized correctly. |
||||
fb.write_changes() |
||||
handle = fb.handle |
||||
if not isinstance(handle, io.BytesIO): |
||||
raise Exception("File handle changed type somehow!") |
||||
self.assertEqual( |
||||
handle.getvalue(), |
||||
b"012ad5ebcf", |
||||
) |
||||
|
||||
fb[7:3:-2] = b"gh" |
||||
self.assertEqual( |
||||
fb[:], |
||||
b"012adhegcf", |
||||
) |
||||
|
||||
# Verify that it gets serialized correctly. |
||||
fb.write_changes() |
||||
handle = fb.handle |
||||
if not isinstance(handle, io.BytesIO): |
||||
raise Exception("File handle changed type somehow!") |
||||
self.assertEqual( |
||||
handle.getvalue(), |
||||
b"012adhegcf", |
||||
) |
||||
|
||||
def test_resize_fail(self) -> None: |
||||
fb = FileBytes(io.BytesIO(b"0123456789")) |
||||
|
||||
with self.assertRaises(NotImplementedError): |
||||
fb[3:4] = b"long" |
||||
with self.assertRaises(NotImplementedError): |
||||
fb[3:7] = b"" |
||||
|
||||
def test_append_modify(self) -> None: |
||||
fb = FileBytes(io.BytesIO(b"0123456789")) |
||||
|
||||
# Length check. |
||||
self.assertEqual( |
||||
len(fb), |
||||
10, |
||||
) |
||||
|
||||
# Clone this so we can verify that clones don't receive additional modifications. |
||||
clone = fb.clone() |
||||
clone.append(b"abc") |
||||
|
||||
# New length check. |
||||
self.assertEqual( |
||||
len(fb), |
||||
10, |
||||
) |
||||
self.assertEqual( |
||||
len(clone), |
||||
13, |
||||
) |
||||
|
||||
# Verify additional appends work. |
||||
clone.append(b"def") |
||||
|
||||
# New length check. |
||||
self.assertEqual( |
||||
len(fb), |
||||
10, |
||||
) |
||||
self.assertEqual( |
||||
len(clone), |
||||
16, |
||||
) |
||||
|
||||
# Verify modification stuck. |
||||
self.assertEqual( |
||||
fb[:], |
||||
b"0123456789", |
||||
) |
||||
self.assertEqual( |
||||
clone[:], |
||||
b"0123456789abcdef", |
||||
) |
||||
|
||||
# Verify that it gets serialized correctly. |
||||
clone.write_changes() |
||||
handle = clone.handle |
||||
if not isinstance(handle, io.BytesIO): |
||||
raise Exception("File handle changed type somehow!") |
||||
self.assertEqual( |
||||
handle.getvalue(), |
||||
b"0123456789abcdef", |
||||
) |
||||
|
||||
def test_modify_writeback_clones_unsafe(self) -> None: |
||||
fb = FileBytes(io.BytesIO(b"0123456789")) |
||||
|
||||
clone = fb.clone() |
||||
clone.append(b"abcdef") |
||||
|
||||
# Verify modification stuck. |
||||
self.assertEqual( |
||||
fb[:], |
||||
b"0123456789", |
||||
) |
||||
self.assertEqual( |
||||
clone[:], |
||||
b"0123456789abcdef", |
||||
) |
||||
|
||||
# Verify that it gets serialized correctly. |
||||
clone.write_changes() |
||||
handle = clone.handle |
||||
if not isinstance(handle, io.BytesIO): |
||||
raise Exception("File handle changed type somehow!") |
||||
self.assertEqual( |
||||
handle.getvalue(), |
||||
b"0123456789abcdef", |
||||
) |
||||
|
||||
# Verify that accessing the clone data works. |
||||
self.assertEqual( |
||||
clone[:], |
||||
b"0123456789abcdef", |
||||
) |
||||
clone[0:1] = b"z" |
||||
self.assertEqual( |
||||
clone[:], |
||||
b"z123456789abcdef", |
||||
) |
||||
|
||||
# Verify that attempting to read or modify the original raises |
||||
# an error since we wrote the clone back to the original file. |
||||
with self.assertRaisesRegex(Exception, "Another FileBytes instance representing the same file was written back!"): |
||||
fb[:] |
||||
with self.assertRaisesRegex(Exception, "Another FileBytes instance representing the same file was written back!"): |
||||
fb[5] = 2 |
||||
|
||||
# Verify that making a new clone works properly. |
||||
newclone = fb.clone() |
||||
self.assertEqual( |
||||
newclone[:], |
||||
b"0123456789abcdef", |
||||
) |
||||
|
||||
def test_add(self) -> None: |
||||
fb = FileBytes(io.BytesIO(b"0123456789")) |
||||
|
||||
# Length check. |
||||
self.assertEqual( |
||||
len(fb), |
||||
10, |
||||
) |
||||
|
||||
# Create a copy by adding to the original. |
||||
clone = fb + b"abc" |
||||
|
||||
# New length check. |
||||
self.assertEqual( |
||||
len(fb), |
||||
10, |
||||
) |
||||
self.assertEqual( |
||||
len(clone), |
||||
13, |
||||
) |
||||
|
||||
# Verify additional appends work including from FileBytes. |
||||
clone = clone + FileBytes(io.BytesIO(b"def")) |
||||
|
||||
# New length check. |
||||
self.assertEqual( |
||||
len(fb), |
||||
10, |
||||
) |
||||
self.assertEqual( |
||||
len(clone), |
||||
16, |
||||
) |
||||
|
||||
# Verify modification stuck. |
||||
self.assertEqual( |
||||
fb[:], |
||||
b"0123456789", |
||||
) |
||||
self.assertEqual( |
||||
clone[:], |
||||
b"0123456789abcdef", |
||||
) |
||||
|
||||
# Verify that it gets serialized correctly. |
||||
clone.write_changes() |
||||
handle = clone.handle |
||||
if not isinstance(handle, io.BytesIO): |
||||
raise Exception("File handle changed type somehow!") |
||||
self.assertEqual( |
||||
handle.getvalue(), |
||||
b"0123456789abcdef", |
||||
) |
||||
|
||||
def test_truncate_noop(self) -> None: |
||||
fb = FileBytes(io.BytesIO(b"0123456789")) |
||||
|
||||
# Length check. |
||||
self.assertEqual( |
||||
len(fb), |
||||
10, |
||||
) |
||||
|
||||
# Create a copy by adding to the original. |
||||