Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions kloppy/_providers/skillcorner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 14 additions & 13 deletions kloppy/_providers/wyscout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
9 changes: 9 additions & 0 deletions kloppy/infra/io/buffered_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
21 changes: 19 additions & 2 deletions kloppy/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions kloppy/tests/issues/test_issue_469.py
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions kloppy/tests/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading