diff --git a/kloppy/_providers/skillcorner.py b/kloppy/_providers/skillcorner.py index 97a6003e..4f4265fe 100644 --- a/kloppy/_providers/skillcorner.py +++ b/kloppy/_providers/skillcorner.py @@ -41,20 +41,22 @@ def load( raise ValueError( f"data_version must be either 'V2', 'V3'. Provided: {data_version}" ) - if not data_version: - data_version = identify_data_version(raw_data) - deserializer = SkillCornerDeserializer( - sample_rate=sample_rate, - limit=limit, - coordinate_system=coordinates, - include_empty_frames=include_empty_frames, - data_version=data_version, - only_alive=only_alive, - ) with ( open_as_file(meta_data) as meta_data_fp, open_as_file(raw_data) as raw_data_fp, ): + if not data_version: + data_version = identify_data_version(raw_data_fp) + raw_data_fp.seek(0) + + deserializer = SkillCornerDeserializer( + sample_rate=sample_rate, + limit=limit, + coordinate_system=coordinates, + include_empty_frames=include_empty_frames, + data_version=data_version, + only_alive=only_alive, + ) return deserializer.deserialize( inputs=SkillCornerInputs( meta_data=meta_data_fp, raw_data=raw_data_fp diff --git a/kloppy/_providers/wyscout.py b/kloppy/_providers/wyscout.py index af4b1902..5b847fb8 100644 --- a/kloppy/_providers/wyscout.py +++ b/kloppy/_providers/wyscout.py @@ -32,20 +32,21 @@ def load( Returns: The parsed event data. """ - if data_version == "V2": - deserializer_class = WyscoutDeserializerV2 - elif data_version == "V3": - deserializer_class = WyscoutDeserializerV3 - else: - deserializer_class = identify_deserializer(event_data) - - deserializer = deserializer_class( - event_types=event_types, - coordinate_system=coordinates, - event_factory=event_factory or get_config("event_factory"), - ) - with open_as_file(event_data) as event_data_fp: + if data_version == "V2": + deserializer_class = WyscoutDeserializerV2 + elif data_version == "V3": + deserializer_class = WyscoutDeserializerV3 + else: + deserializer_class = identify_deserializer(event_data_fp) + event_data_fp.seek(0) + + deserializer = deserializer_class( + event_types=event_types, + coordinate_system=coordinates, + event_factory=event_factory or get_config("event_factory"), + ) + return deserializer.deserialize( inputs=WyscoutInputs(event_data=event_data_fp), ) diff --git a/kloppy/infra/io/buffered_stream.py b/kloppy/infra/io/buffered_stream.py index 5bc1ba4f..cc06eb79 100644 --- a/kloppy/infra/io/buffered_stream.py +++ b/kloppy/infra/io/buffered_stream.py @@ -25,6 +25,15 @@ class BufferedStream(tempfile.SpooledTemporaryFile): def __init__(self, max_size: int = DEFAULT_BUFFER_SIZE, mode: str = "w+b"): super().__init__(max_size=max_size, mode=mode) + def readable(self) -> bool: + return True + + def writable(self) -> bool: + return True + + def seekable(self) -> bool: + return True + def write(self, data: bytes) -> int: # make it clearly bytes-only return super().write(data) diff --git a/kloppy/io.py b/kloppy/io.py index 270dbc88..a140096e 100644 --- a/kloppy/io.py +++ b/kloppy/io.py @@ -453,10 +453,21 @@ def open_as_file( if not isinstance(input_, (str, os.PathLike)): input_mode = getattr(input_, "mode", None) if input_mode and input_mode != mode: - raise ValueError( - f"File opened in mode '{input_mode}' but '{mode}' requested" + is_readable_requested = "r" in mode + is_writable_requested = "w" in mode or "a" in mode + + is_readable_actual = "r" in input_mode or "+" in input_mode + is_writable_actual = ( + "w" in input_mode or "a" in input_mode or "+" in input_mode ) + if (is_readable_requested and not is_readable_actual) or ( + is_writable_requested and not is_writable_actual + ): + raise ValueError( + f"File opened in mode '{input_mode}' but '{mode}' requested" + ) + # --- Processing: Open or wrap the input --- # _open handles: # 1. Opening paths @@ -474,6 +485,12 @@ def open_as_file( if hasattr(input_, "buffer"): is_transformed = is_transformed and opened is not input_.buffer + if mode == "rb": + is_seekable = getattr(opened, "seekable", lambda: False)() + if not is_seekable: + opened = BufferedStream.from_stream(opened) + is_transformed = True + if is_transformed: # Exception: If the original input was a file object, and _open returned a # compression wrapper (like GzipFile), closing GzipFile usually closes the diff --git a/kloppy/tests/issues/test_issue_469.py b/kloppy/tests/issues/test_issue_469.py new file mode 100644 index 00000000..839a2ab7 --- /dev/null +++ b/kloppy/tests/issues/test_issue_469.py @@ -0,0 +1,53 @@ +from io import BytesIO +from pathlib import Path + +from kloppy import skillcorner, wyscout + + +class NonSeekableStream: + def __init__(self, data: bytes): + self._data = BytesIO(data) + + def read(self, *args, **kwargs): + return self._data.read(*args, **kwargs) + + def readinto(self, *args, **kwargs): + return self._data.readinto(*args, **kwargs) + + def seekable(self): + return False + + def readable(self): + return True + + +def test_wyscout_non_seekable(base_dir: Path): + event_v2_data = base_dir / "files" / "wyscout_events_v2.json" + with open(event_v2_data, "rb") as f: + data = f.read() + + stream = NonSeekableStream(data) + # This should not raise an error and successfully load + dataset = wyscout.load(event_data=stream, coordinates="wyscout") + assert len(dataset.records) > 0 + + +def test_skillcorner_non_seekable(base_dir: Path): + meta_data = base_dir / "files" / "skillcorner_match_data.json" + raw_data = base_dir / "files" / "skillcorner_structured_data.json" + + with open(meta_data, "rb") as f: + meta = f.read() + + with open(raw_data, "rb") as f: + raw = f.read() + + meta_stream = NonSeekableStream(meta) + raw_stream = NonSeekableStream(raw) + + dataset = skillcorner.load( + meta_data=meta_stream, + raw_data=raw_stream, + coordinates="skillcorner", + ) + assert len(dataset.records) > 0 diff --git a/kloppy/tests/test_io.py b/kloppy/tests/test_io.py index 79abaddf..57357cca 100644 --- a/kloppy/tests/test_io.py +++ b/kloppy/tests/test_io.py @@ -104,6 +104,33 @@ def test_read_stream(self): with open_as_file(BytesIO(data)) as fp: assert fp.read() == data + def test_read_non_seekable_stream(self): + """It should automatically wrap non-seekable streams in a BufferedStream.""" + + class NonSeekableStream: + def __init__(self, data: bytes): + self._data = BytesIO(data) + + def read(self, *args, **kwargs): + return self._data.read(*args, **kwargs) + + def readinto(self, *args, **kwargs): + return self._data.readinto(*args, **kwargs) + + def seekable(self): + return False + + def readable(self): + return True + + data = b"Hello, non-seekable world!" + stream = NonSeekableStream(data) + with open_as_file(stream) as fp: + assert getattr(fp, "seekable", lambda: False)() is True + assert fp.read() == data + fp.seek(0) + assert fp.read() == data + @pytest.mark.parametrize( "compress_func", [gzip.compress, bz2.compress, lzma.compress],