From e0f702b0047cb322f334df8fbd477526a2a292ac Mon Sep 17 00:00:00 2001 From: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:02:39 +0000 Subject: [PATCH] Type Python sources as anything with read(size) -> bytes BinaryIO rejected the streams the standard library returns, such as a zipfile member (IO[bytes]), though the reader accepts them at runtime. --- .../python/spliterator/__init__.py | 10 ++++++---- .../python/spliterator/_native.pyi | 6 ++++-- .../spliterator-py/python/spliterator/_types.py | 16 ++++++++++++++++ crates/spliterator-py/tests/test_sources.py | 12 ++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 crates/spliterator-py/python/spliterator/_types.py diff --git a/crates/spliterator-py/python/spliterator/__init__.py b/crates/spliterator-py/python/spliterator/__init__.py index eb3f29e..c3a9623 100644 --- a/crates/spliterator-py/python/spliterator/__init__.py +++ b/crates/spliterator-py/python/spliterator/__init__.py @@ -23,10 +23,11 @@ import os from collections.abc import AsyncIterator, Callable, Iterable, Iterator, Mapping from importlib.metadata import PackageNotFoundError, version -from typing import Any, BinaryIO, Literal, TypeAlias, TypedDict, TypeVar, Unpack +from typing import Any, Literal, TypeAlias, TypedDict, TypeVar, Unpack from . import _native from ._errors import DuplicateHeaderError, JSONRowError, SpliteratorError, UnterminatedQuoteError +from ._types import SupportsRead __all__ = [ "CSVOptions", @@ -35,6 +36,7 @@ "JSONRowError", "Source", "SpliteratorError", + "SupportsRead", "TextOptions", "UnterminatedQuoteError", "aread_csv", @@ -58,8 +60,8 @@ except PackageNotFoundError: # pragma: no cover - a source checkout without an install __version__ = "0.0.0" -Source: TypeAlias = str | os.PathLike[str] | bytes | bytearray | memoryview | BinaryIO -"""A path, bytes-like data, or a binary file object.""" +Source: TypeAlias = str | os.PathLike[str] | bytes | bytearray | memoryview | SupportsRead +"""A path, bytes-like data, or a binary stream (anything with ``read(size) -> bytes``).""" Mode: TypeAlias = Literal["object", "array", "entries"] @@ -207,7 +209,7 @@ def _merge(defaults: Mapping[str, Any], options: Mapping[str, Any], reader: str) return {**defaults, **options} -def _source(source: Source) -> str | bytes | BinaryIO: +def _source(source: Source) -> str | bytes | SupportsRead: if isinstance(source, (bytes, bytearray, memoryview)): return bytes(source) diff --git a/crates/spliterator-py/python/spliterator/_native.pyi b/crates/spliterator-py/python/spliterator/_native.pyi index fc2cb08..93f1521 100644 --- a/crates/spliterator-py/python/spliterator/_native.pyi +++ b/crates/spliterator-py/python/spliterator/_native.pyi @@ -1,7 +1,9 @@ from collections.abc import Iterator -from typing import Any, BinaryIO, TypeAlias +from typing import Any, TypeAlias -_Source: TypeAlias = str | bytes | BinaryIO +from ._types import SupportsRead + +_Source: TypeAlias = str | bytes | SupportsRead class TextBatches(Iterator[list[str]]): def __init__( diff --git a/crates/spliterator-py/python/spliterator/_types.py b/crates/spliterator-py/python/spliterator/_types.py new file mode 100644 index 0000000..929e888 --- /dev/null +++ b/crates/spliterator-py/python/spliterator/_types.py @@ -0,0 +1,16 @@ +"""Types shared by the public module and the native stubs.""" + +from __future__ import annotations + +from typing import Protocol + + +class SupportsRead(Protocol): + """A binary stream: anything with ``read(size) -> bytes``. + + Structural rather than ``BinaryIO``, so the objects the standard library hands out type-check as they are: an + ``open(path, "rb")`` handle, ``io.BytesIO``, a ``zipfile.ZipFile.open`` member, ``gzip.open``, a socket's + ``makefile("rb")``. + """ + + def read(self, size: int = ..., /) -> bytes: ... diff --git a/crates/spliterator-py/tests/test_sources.py b/crates/spliterator-py/tests/test_sources.py index 5b09a17..d24c9b4 100644 --- a/crates/spliterator-py/tests/test_sources.py +++ b/crates/spliterator-py/tests/test_sources.py @@ -6,6 +6,7 @@ import asyncio import gzip import io +import zipfile from pathlib import Path from typing import Any @@ -144,3 +145,14 @@ def test_counts_match_reads(tmp_path: Path) -> None: assert spliterator.count_text(path) == 5000 assert spliterator.count_text(path, drop=10, take=7) == 7 assert spliterator.count_csv(b"k,k\n1,2\n3,4\n") == 2 + + +def test_a_zip_member_is_a_source(tmp_path: Path) -> None: + archive_path = tmp_path / "data.zip" + + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr("rows.csv", "a,b\n1,2\n") + + with zipfile.ZipFile(archive_path) as archive, archive.open("rows.csv") as member: + # `ZipFile.open` returns `IO[bytes]`, not `BinaryIO`; the source type must accept it as it is. + assert list(spliterator.read_csv(member)) == [{"a": "1", "b": "2"}]