httk-core 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- httk/core/__init__.py +112 -0
- httk/core/_discover.py +35 -0
- httk/core/_plugins.py +95 -0
- httk/core/dataloader.py +263 -0
- httk/core/datastream/__init__.py +80 -0
- httk/core/datastream/bytestream_api.py +33 -0
- httk/core/datastream/bytestream_backend.py +12 -0
- httk/core/datastream/bytestream_bytes.py +52 -0
- httk/core/datastream/bytestream_bytes_view.py +31 -0
- httk/core/datastream/bytestream_common.py +38 -0
- httk/core/datastream/bytestream_file.py +53 -0
- httk/core/datastream/bytestream_file_view.py +189 -0
- httk/core/datastream/bytestream_filename.py +53 -0
- httk/core/datastream/bytestream_filename_view.py +34 -0
- httk/core/datastream/bytestream_like.py +17 -0
- httk/core/datastream/bytestream_request.py +69 -0
- httk/core/datastream/bytestream_request_view.py +44 -0
- httk/core/datastream/bytestream_url.py +74 -0
- httk/core/datastream/bytestream_url_view.py +35 -0
- httk/core/datastream/bytestream_view.py +16 -0
- httk/core/datastream/compression.py +183 -0
- httk/core/datastream/textstream_api.py +33 -0
- httk/core/datastream/textstream_backend.py +12 -0
- httk/core/datastream/textstream_common.py +37 -0
- httk/core/datastream/textstream_file.py +45 -0
- httk/core/datastream/textstream_file_view.py +201 -0
- httk/core/datastream/textstream_filename.py +55 -0
- httk/core/datastream/textstream_filename_view.py +35 -0
- httk/core/datastream/textstream_like.py +15 -0
- httk/core/datastream/textstream_request.py +72 -0
- httk/core/datastream/textstream_request_view.py +44 -0
- httk/core/datastream/textstream_string.py +47 -0
- httk/core/datastream/textstream_string_view.py +31 -0
- httk/core/datastream/textstream_url.py +77 -0
- httk/core/datastream/textstream_url_view.py +35 -0
- httk/core/datastream/textstream_view.py +16 -0
- httk/core/loading.py +29 -0
- httk/core/py.typed +0 -0
- httk/core/register.py +30 -0
- httk/core/views/__init__.py +5 -0
- httk/core/views/backend.py +49 -0
- httk/core/views/unwrapping.py +12 -0
- httk/core/views/view.py +56 -0
- httk/handlers/core/__init__.py +0 -0
- httk_core-2.0.0.dist-info/METADATA +76 -0
- httk_core-2.0.0.dist-info/RECORD +49 -0
- httk_core-2.0.0.dist-info/WHEEL +5 -0
- httk_core-2.0.0.dist-info/licenses/LICENSE +661 -0
- httk_core-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import bz2
|
|
2
|
+
import gzip
|
|
3
|
+
import io
|
|
4
|
+
import lzma
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import IO, cast
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class CompressionCodec:
|
|
12
|
+
"""
|
|
13
|
+
A decompression codec for a single container format.
|
|
14
|
+
|
|
15
|
+
A codec is an orthogonal layer below the datastream backends: it turns a compressed
|
|
16
|
+
binary stream into an uncompressed binary stream, independently of where the compressed
|
|
17
|
+
bytes come from (a filename, an open file, raw bytes, or a remote response).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
"""Canonical, lower-case codec name (e.g. ``"gzip"``); also how an explicit hint selects it."""
|
|
22
|
+
|
|
23
|
+
extensions: tuple[str, ...]
|
|
24
|
+
"""Recognized filename suffixes including the leading dot (e.g. ``(".gz",)``)."""
|
|
25
|
+
|
|
26
|
+
magics: tuple[bytes, ...]
|
|
27
|
+
"""Leading magic-byte signatures; an empty tuple means the format cannot be sniffed."""
|
|
28
|
+
|
|
29
|
+
open_stream: Callable[[io.IOBase], io.IOBase]
|
|
30
|
+
"""Wrap a compressed binary stream and return a readable, decompressed binary stream."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_registry: dict[str, CompressionCodec] = {}
|
|
34
|
+
_MODES = frozenset({"auto", "detect", "extension", "none"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register_compression(codec: CompressionCodec) -> None:
|
|
38
|
+
"""Register (or replace) a codec under its :attr:`~CompressionCodec.name` (case-insensitive)."""
|
|
39
|
+
_registry[codec.name.lower()] = codec
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def known_compressions() -> list[str]:
|
|
43
|
+
"""Return the registered codec names, in registration order."""
|
|
44
|
+
return list(_registry)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def codec_for_name(name: str) -> CompressionCodec | None:
|
|
48
|
+
"""Return the codec whose extension matches the trailing suffix of ``name``, else ``None``."""
|
|
49
|
+
lowered = name.lower()
|
|
50
|
+
for codec in _registry.values():
|
|
51
|
+
for ext in codec.extensions:
|
|
52
|
+
if lowered.endswith(ext.lower()):
|
|
53
|
+
return codec
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def split_compression_suffix(name: str) -> tuple[str, CompressionCodec | None]:
|
|
58
|
+
"""
|
|
59
|
+
Split a trailing compression extension off ``name``.
|
|
60
|
+
|
|
61
|
+
``"data.json.gz"`` becomes ``("data.json", <gzip codec>)``; a name with no recognized
|
|
62
|
+
compression extension is returned unchanged with ``None``.
|
|
63
|
+
"""
|
|
64
|
+
codec = codec_for_name(name)
|
|
65
|
+
if codec is None:
|
|
66
|
+
return name, None
|
|
67
|
+
lowered = name.lower()
|
|
68
|
+
for ext in codec.extensions:
|
|
69
|
+
if lowered.endswith(ext.lower()):
|
|
70
|
+
return name[: -len(ext)], codec
|
|
71
|
+
return name, codec
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _max_magic_len() -> int:
|
|
75
|
+
return max((len(magic) for codec in _registry.values() for magic in codec.magics), default=0)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _match_magic(prefix: bytes) -> CompressionCodec | None:
|
|
79
|
+
for codec in _registry.values():
|
|
80
|
+
for magic in codec.magics:
|
|
81
|
+
if magic and prefix.startswith(magic):
|
|
82
|
+
return codec
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def sniff_codec(stream: io.IOBase) -> tuple[io.IOBase, CompressionCodec | None]:
|
|
87
|
+
"""
|
|
88
|
+
Detect a codec from the leading magic bytes of ``stream`` without consuming data.
|
|
89
|
+
|
|
90
|
+
A seekable stream is read and rewound; an unseekable stream is peeked (directly when it
|
|
91
|
+
supports ``peek``, otherwise via a wrapping :class:`io.BufferedReader`). The returned
|
|
92
|
+
stream must be used in place of the input, since it may be the wrapper.
|
|
93
|
+
"""
|
|
94
|
+
max_len = _max_magic_len()
|
|
95
|
+
if max_len == 0:
|
|
96
|
+
return stream, None
|
|
97
|
+
if stream.seekable():
|
|
98
|
+
reader = cast(io.BufferedIOBase, stream)
|
|
99
|
+
pos = reader.tell()
|
|
100
|
+
prefix = reader.read(max_len)
|
|
101
|
+
reader.seek(pos)
|
|
102
|
+
return stream, _match_magic(prefix)
|
|
103
|
+
peek = getattr(stream, "peek", None)
|
|
104
|
+
if callable(peek):
|
|
105
|
+
return stream, _match_magic(cast(bytes, peek(max_len))[:max_len])
|
|
106
|
+
buffered = io.BufferedReader(cast(io.RawIOBase, stream))
|
|
107
|
+
return buffered, _match_magic(buffered.peek(max_len)[:max_len])
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def open_compressed(stream: io.IOBase, *, compression: str = "auto", name: str | None = None) -> io.IOBase:
|
|
111
|
+
"""
|
|
112
|
+
Return a decompressed view of ``stream`` according to the ``compression`` hint.
|
|
113
|
+
|
|
114
|
+
Values are ``"none"`` (passthrough), ``"extension"`` (decide from ``name`` only),
|
|
115
|
+
``"detect"`` (always sniff magic bytes), ``"auto"`` (extension if recognized, else sniff),
|
|
116
|
+
or a registered codec name (force that codec; an unknown name raises :class:`ValueError`).
|
|
117
|
+
When no codec applies the stream is returned unchanged.
|
|
118
|
+
"""
|
|
119
|
+
token = compression.lower()
|
|
120
|
+
if token == "none":
|
|
121
|
+
return stream
|
|
122
|
+
if token == "extension":
|
|
123
|
+
codec = codec_for_name(name) if name is not None else None
|
|
124
|
+
return codec.open_stream(stream) if codec is not None else stream
|
|
125
|
+
if token == "auto":
|
|
126
|
+
codec = codec_for_name(name) if name is not None else None
|
|
127
|
+
if codec is not None:
|
|
128
|
+
return codec.open_stream(stream)
|
|
129
|
+
stream, codec = sniff_codec(stream)
|
|
130
|
+
return codec.open_stream(stream) if codec is not None else stream
|
|
131
|
+
if token == "detect":
|
|
132
|
+
stream, codec = sniff_codec(stream)
|
|
133
|
+
return codec.open_stream(stream) if codec is not None else stream
|
|
134
|
+
codec = _registry.get(token)
|
|
135
|
+
if codec is None:
|
|
136
|
+
raise ValueError(f"unknown compression codec {compression!r}; known codecs: {known_compressions()}")
|
|
137
|
+
return codec.open_stream(stream)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def validate_compression(compression: str) -> None:
|
|
141
|
+
"""Raise :class:`ValueError` unless ``compression`` is a known mode or registered codec name."""
|
|
142
|
+
token = compression.lower()
|
|
143
|
+
if token in _MODES or token in _registry:
|
|
144
|
+
return
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"unknown compression {compression!r}; expected one of {sorted(_MODES)} "
|
|
147
|
+
f"or a registered codec name ({known_compressions()})"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def reject_text_native_compression(compression: str | None) -> None:
|
|
152
|
+
"""
|
|
153
|
+
Validate a ``compression`` hint for a text-native source (an open text stream or a string).
|
|
154
|
+
|
|
155
|
+
Such sources carry no compressed bytes to decode, so only the no-op modes ``"auto"``,
|
|
156
|
+
``"extension"``, and ``"none"`` are accepted; a codec name or ``"detect"`` raises
|
|
157
|
+
:class:`ValueError`. ``None`` (no hint given) is accepted.
|
|
158
|
+
"""
|
|
159
|
+
if compression is None or compression.lower() in ("auto", "extension", "none"):
|
|
160
|
+
return
|
|
161
|
+
raise ValueError(f"compression={compression!r} does not apply to a text-native source")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _open_gzip(stream: io.IOBase) -> io.IOBase:
|
|
165
|
+
return gzip.GzipFile(fileobj=cast(IO[bytes], stream))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _open_bzip2(stream: io.IOBase) -> io.IOBase:
|
|
169
|
+
return bz2.BZ2File(cast(IO[bytes], stream))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _open_xz(stream: io.IOBase) -> io.IOBase:
|
|
173
|
+
return lzma.LZMAFile(cast(IO[bytes], stream))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _open_lzma(stream: io.IOBase) -> io.IOBase:
|
|
177
|
+
return lzma.LZMAFile(cast(IO[bytes], stream), format=lzma.FORMAT_ALONE)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
register_compression(CompressionCodec("gzip", (".gz",), (b"\x1f\x8b",), _open_gzip))
|
|
181
|
+
register_compression(CompressionCodec("bzip2", (".bz2",), (b"BZh",), _open_bzip2))
|
|
182
|
+
register_compression(CompressionCodec("xz", (".xz",), (b"\xfd7zXZ\x00",), _open_xz))
|
|
183
|
+
register_compression(CompressionCodec("lzma", (".lzma",), (), _open_lzma))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TextstreamAPI(ABC):
|
|
5
|
+
"""
|
|
6
|
+
Abstract base class for a bare minimum API for streamable text data.
|
|
7
|
+
|
|
8
|
+
Supports:
|
|
9
|
+
* read, close, name, and closed with the meanings defined by io.IOBase
|
|
10
|
+
|
|
11
|
+
Since it is a *minimal* streaming data API it deliberately omits:
|
|
12
|
+
seek, tell, etc.; there should be no assumption that the underlying
|
|
13
|
+
data source is seekable. However, many backend implementations
|
|
14
|
+
may chose to support them.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def read(self, size: int = -1) -> str:
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def close(self) -> None:
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def name(self) -> str | None:
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def closed(self) -> bool:
|
|
33
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import Any, ClassVar
|
|
2
|
+
|
|
3
|
+
from ..views import Backend
|
|
4
|
+
from .textstream_api import TextstreamAPI
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TextstreamBackend(Backend["TextstreamBackend"], TextstreamAPI):
|
|
8
|
+
"""
|
|
9
|
+
Abstract base class for all backends of streaming text data.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
backend_classes: ClassVar[list[type[Backend[Any]]]]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TextstreamCommon(ABC):
|
|
7
|
+
"""
|
|
8
|
+
Common superclass for many of the implementations of backends for streaming text data.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
_f: io.TextIOBase | None
|
|
12
|
+
_underlying: io.IOBase | None
|
|
13
|
+
_closed: bool
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def _ensure_f(self) -> io.TextIOBase:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
def unwrap(self) -> io.TextIOBase:
|
|
20
|
+
return self._ensure_f()
|
|
21
|
+
|
|
22
|
+
def read(self, size: int = -1) -> str:
|
|
23
|
+
return self._ensure_f().read(size)
|
|
24
|
+
|
|
25
|
+
def close(self) -> None:
|
|
26
|
+
if self._f is not None and not self._f.closed:
|
|
27
|
+
self._f.close()
|
|
28
|
+
# A text wrapper closes the stream it wraps but not a decompression source below it.
|
|
29
|
+
if self._underlying is not None and self._underlying is not self._f and not self._underlying.closed:
|
|
30
|
+
self._underlying.close()
|
|
31
|
+
self._closed = True
|
|
32
|
+
|
|
33
|
+
def seek(self, offset: int, whence: int = os.SEEK_SET) -> int:
|
|
34
|
+
return self._ensure_f().seek(offset, whence)
|
|
35
|
+
|
|
36
|
+
def tell(self) -> int:
|
|
37
|
+
return self._ensure_f().tell()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from .compression import reject_text_native_compression
|
|
5
|
+
from .textstream_backend import TextstreamBackend
|
|
6
|
+
from .textstream_common import TextstreamCommon
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TextstreamFile(TextstreamCommon, TextstreamBackend):
|
|
10
|
+
"""
|
|
11
|
+
Backend for file-based (io.TextIOBase-conforming) streaming text data
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
_f: io.TextIOBase | None
|
|
15
|
+
_underlying: io.IOBase | None
|
|
16
|
+
_closed: bool
|
|
17
|
+
|
|
18
|
+
# mypy does not allow to type annotate __new__ as `Self | None` for some reason
|
|
19
|
+
def __new__(cls, obj: io.TextIOBase, **hints: Any) -> Any:
|
|
20
|
+
if not isinstance(obj, io.TextIOBase):
|
|
21
|
+
return None
|
|
22
|
+
if hints and hints.get("kind", "file") != "file":
|
|
23
|
+
return None
|
|
24
|
+
return super().__new__(cls)
|
|
25
|
+
|
|
26
|
+
def __init__(self, obj: io.TextIOBase, **hints: Any) -> None:
|
|
27
|
+
reject_text_native_compression(hints.get("compression"))
|
|
28
|
+
self._f = obj
|
|
29
|
+
# Compression cannot layer under an already-decoded text stream, so nothing to chain-close.
|
|
30
|
+
self._underlying = None
|
|
31
|
+
self._closed = False
|
|
32
|
+
|
|
33
|
+
def _ensure_f(self) -> io.TextIOBase:
|
|
34
|
+
if self._f is None or self._f.closed:
|
|
35
|
+
raise ValueError("I/O operation on closed stream")
|
|
36
|
+
return self._f
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str | None:
|
|
40
|
+
self._ensure_f()
|
|
41
|
+
return getattr(self._f, "name", None)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def closed(self) -> bool:
|
|
45
|
+
return self._f is None or self._f.closed
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from typing import Any, Iterator, NoReturn, Self
|
|
3
|
+
|
|
4
|
+
from ..views import unwrap
|
|
5
|
+
from .textstream_api import TextstreamAPI
|
|
6
|
+
from .textstream_backend import TextstreamBackend
|
|
7
|
+
from .textstream_like import TextstreamLike
|
|
8
|
+
from .textstream_view import TextstreamView
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TextstreamFileView(TextstreamView, io.TextIOBase, TextstreamAPI):
|
|
12
|
+
"""
|
|
13
|
+
A view presenting an underlying data streaming backend via the full io.TextIOBase API, which is a superset of TextstreamAPI.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
_backend: TextstreamBackend
|
|
17
|
+
_readline_buffer: str
|
|
18
|
+
|
|
19
|
+
def __new__(cls, obj: TextstreamLike, **hints: Any) -> Self:
|
|
20
|
+
if isinstance(obj, cls):
|
|
21
|
+
return obj
|
|
22
|
+
backend = cls._prepare_backend(obj, hints)
|
|
23
|
+
instance = super().__new__(cls)
|
|
24
|
+
instance._backend = backend
|
|
25
|
+
instance._readline_buffer = ""
|
|
26
|
+
return instance
|
|
27
|
+
|
|
28
|
+
def __init__(self, obj: TextstreamLike, **hints: Any) -> None:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
def unwrap(self) -> Any:
|
|
32
|
+
return unwrap(self._backend)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def name(self) -> str | None:
|
|
36
|
+
return self._backend.name
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def closed(self) -> bool:
|
|
40
|
+
return self._backend.closed
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
self._backend.close()
|
|
44
|
+
|
|
45
|
+
def readable(self) -> bool:
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
def writable(self) -> bool:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def seekable(self) -> bool:
|
|
52
|
+
return hasattr(self._backend, "seek") and hasattr(self._backend, "tell")
|
|
53
|
+
|
|
54
|
+
def flush(self) -> None:
|
|
55
|
+
if self.closed:
|
|
56
|
+
raise ValueError("I/O operation on closed file.")
|
|
57
|
+
flush = getattr(self._backend, "flush", None)
|
|
58
|
+
if flush:
|
|
59
|
+
flush()
|
|
60
|
+
|
|
61
|
+
def read(self, size: int | None = -1) -> str:
|
|
62
|
+
if self.closed:
|
|
63
|
+
raise ValueError("I/O operation on closed file.")
|
|
64
|
+
|
|
65
|
+
if size is None or size < 0:
|
|
66
|
+
if self._readline_buffer:
|
|
67
|
+
prefix = self._readline_buffer
|
|
68
|
+
self._readline_buffer = ""
|
|
69
|
+
return prefix + self._backend.read()
|
|
70
|
+
return self._backend.read()
|
|
71
|
+
|
|
72
|
+
if size == 0:
|
|
73
|
+
return ""
|
|
74
|
+
|
|
75
|
+
if self._readline_buffer:
|
|
76
|
+
if len(self._readline_buffer) >= size:
|
|
77
|
+
out = self._readline_buffer[:size]
|
|
78
|
+
self._readline_buffer = self._readline_buffer[size:]
|
|
79
|
+
return out
|
|
80
|
+
prefix = self._readline_buffer
|
|
81
|
+
self._readline_buffer = ""
|
|
82
|
+
return prefix + self._backend.read(size - len(prefix))
|
|
83
|
+
|
|
84
|
+
return self._backend.read(size)
|
|
85
|
+
|
|
86
|
+
def readline(self, size: int | None = -1) -> str: # type: ignore[override] ## https://github.com/python/mypy/issues/9643
|
|
87
|
+
if self.closed:
|
|
88
|
+
raise ValueError("I/O operation on closed file.")
|
|
89
|
+
if size == 0:
|
|
90
|
+
return ""
|
|
91
|
+
if size is None:
|
|
92
|
+
size = -1
|
|
93
|
+
|
|
94
|
+
parts: list[str] = []
|
|
95
|
+
total = 0
|
|
96
|
+
|
|
97
|
+
while True:
|
|
98
|
+
if self._readline_buffer:
|
|
99
|
+
chunk = self._readline_buffer
|
|
100
|
+
self._readline_buffer = ""
|
|
101
|
+
else:
|
|
102
|
+
to_read = 8192
|
|
103
|
+
if size >= 0:
|
|
104
|
+
remaining = size - total
|
|
105
|
+
if remaining <= 0:
|
|
106
|
+
break
|
|
107
|
+
to_read = min(to_read, remaining)
|
|
108
|
+
chunk = self._backend.read(to_read)
|
|
109
|
+
|
|
110
|
+
if chunk == "":
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
newline_pos = chunk.find("\n")
|
|
114
|
+
if newline_pos != -1:
|
|
115
|
+
newline_pos += 1
|
|
116
|
+
take = chunk[:newline_pos]
|
|
117
|
+
rest = chunk[newline_pos:]
|
|
118
|
+
|
|
119
|
+
if size >= 0 and total + len(take) > size:
|
|
120
|
+
cutoff = size - total
|
|
121
|
+
parts.append(take[:cutoff])
|
|
122
|
+
self._readline_buffer = take[cutoff:] + rest
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
parts.append(take)
|
|
126
|
+
self._readline_buffer = rest
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
if size >= 0 and total + len(chunk) > size:
|
|
130
|
+
cutoff = size - total
|
|
131
|
+
parts.append(chunk[:cutoff])
|
|
132
|
+
self._readline_buffer = chunk[cutoff:]
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
parts.append(chunk)
|
|
136
|
+
total += len(chunk)
|
|
137
|
+
|
|
138
|
+
return "".join(parts)
|
|
139
|
+
|
|
140
|
+
def readlines(self, hint: int = -1) -> list[str]: # type: ignore[override] ## https://github.com/python/mypy/issues/9643
|
|
141
|
+
if self.closed:
|
|
142
|
+
raise ValueError("I/O operation on closed file.")
|
|
143
|
+
|
|
144
|
+
lines: list[str] = []
|
|
145
|
+
total = 0
|
|
146
|
+
|
|
147
|
+
while True:
|
|
148
|
+
line = self.readline()
|
|
149
|
+
if line == "":
|
|
150
|
+
break
|
|
151
|
+
lines.append(line)
|
|
152
|
+
total += len(line)
|
|
153
|
+
if hint >= 0 and total >= hint:
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
return lines
|
|
157
|
+
|
|
158
|
+
def __iter__(self) -> Iterator[str]: # type: ignore[override] ## https://github.com/python/mypy/issues/9643
|
|
159
|
+
return self
|
|
160
|
+
|
|
161
|
+
def __next__(self) -> str: # type: ignore[override] ## https://github.com/python/mypy/issues/9643
|
|
162
|
+
line = self.readline()
|
|
163
|
+
if line == "":
|
|
164
|
+
raise StopIteration
|
|
165
|
+
return line
|
|
166
|
+
|
|
167
|
+
def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
|
|
168
|
+
if self.closed:
|
|
169
|
+
raise ValueError("I/O operation on closed file.")
|
|
170
|
+
seek = getattr(self._backend, "seek", None)
|
|
171
|
+
if not seek:
|
|
172
|
+
raise io.UnsupportedOperation("underlying stream is not seekable")
|
|
173
|
+
|
|
174
|
+
pos = seek(offset, whence)
|
|
175
|
+
self._readline_buffer = ""
|
|
176
|
+
return pos
|
|
177
|
+
|
|
178
|
+
def tell(self) -> int:
|
|
179
|
+
if self.closed:
|
|
180
|
+
raise ValueError("I/O operation on closed file.")
|
|
181
|
+
tell = getattr(self._backend, "tell")
|
|
182
|
+
if not tell:
|
|
183
|
+
raise io.UnsupportedOperation("underlying stream does not support tell()")
|
|
184
|
+
|
|
185
|
+
pos = tell()
|
|
186
|
+
return pos - len(self._readline_buffer)
|
|
187
|
+
|
|
188
|
+
def detach(self) -> NoReturn:
|
|
189
|
+
raise io.UnsupportedOperation("detach")
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def encoding(self) -> str | None: # type: ignore[override] ## these should not be writable
|
|
193
|
+
return getattr(self._backend, "encoding", None)
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def errors(self) -> str | None: # type: ignore[override] ## these should not be writable
|
|
197
|
+
return getattr(self._backend, "errors", None)
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def newlines(self) -> str | tuple[str, ...] | None: # type: ignore[override] ## these should not be writable
|
|
201
|
+
return getattr(self._backend, "newlines", None)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from .compression import open_compressed, validate_compression
|
|
6
|
+
from .textstream_backend import TextstreamBackend
|
|
7
|
+
from .textstream_common import TextstreamCommon
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TextstreamFilename(TextstreamCommon, TextstreamBackend):
|
|
11
|
+
"""
|
|
12
|
+
Backend for streaming text via operations on a file specfied by a filename
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
_filename: str
|
|
16
|
+
_compression: str
|
|
17
|
+
_encoding: str | None
|
|
18
|
+
_f: io.TextIOBase | None
|
|
19
|
+
_underlying: io.IOBase | None
|
|
20
|
+
_closed: bool
|
|
21
|
+
|
|
22
|
+
# mypy does not allow to type annotate __new__ as `Self | None` for some reason
|
|
23
|
+
def __new__(cls, filename: str | Path, **hints: Any) -> Any:
|
|
24
|
+
if not isinstance(filename, str | Path):
|
|
25
|
+
return None
|
|
26
|
+
if hints and hints.get("kind", "filename") != "filename":
|
|
27
|
+
return None
|
|
28
|
+
return super().__new__(cls)
|
|
29
|
+
|
|
30
|
+
def __init__(self, filename: str | Path, **hints: Any) -> None:
|
|
31
|
+
self._filename = str(filename)
|
|
32
|
+
self._compression = hints.get("compression", "extension")
|
|
33
|
+
validate_compression(self._compression)
|
|
34
|
+
self._encoding = hints.get("encoding")
|
|
35
|
+
self._f = None
|
|
36
|
+
self._underlying = None
|
|
37
|
+
self._closed = False
|
|
38
|
+
|
|
39
|
+
def _ensure_f(self) -> io.TextIOBase:
|
|
40
|
+
if self._closed:
|
|
41
|
+
raise ValueError("I/O operation on closed stream")
|
|
42
|
+
if self._f is None:
|
|
43
|
+
raw = open(self._filename, "rb")
|
|
44
|
+
decompressed = open_compressed(raw, compression=self._compression, name=self._filename)
|
|
45
|
+
self._underlying = raw if decompressed is not raw else None
|
|
46
|
+
self._f = io.TextIOWrapper(cast(io.BufferedReader, decompressed), encoding=self._encoding or "utf-8")
|
|
47
|
+
return self._f
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def name(self) -> str | None:
|
|
51
|
+
return self._filename
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def closed(self) -> bool:
|
|
55
|
+
return self._closed
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any, Self
|
|
2
|
+
|
|
3
|
+
from ..views import unwrap
|
|
4
|
+
from .textstream_backend import TextstreamBackend
|
|
5
|
+
from .textstream_like import TextstreamLike
|
|
6
|
+
from .textstream_view import TextstreamView
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TextstreamFilenameView(TextstreamView, str):
|
|
10
|
+
"""
|
|
11
|
+
A view presenting an underlying data streaming backend via a filename.
|
|
12
|
+
This view is probably mostly useful for providing filenames to functions that will open them.
|
|
13
|
+
Note: this view is not lazy (this is impossible for views inherting str, since str is immutable)
|
|
14
|
+
|
|
15
|
+
Raises TypeError if created with a streaming data source that do not come with a name, in the future
|
|
16
|
+
we may consider creating a temporary file to place the data in.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
_backend: TextstreamBackend
|
|
20
|
+
|
|
21
|
+
def __new__(cls, obj: TextstreamLike, **hints: Any) -> Self:
|
|
22
|
+
if isinstance(obj, cls):
|
|
23
|
+
return obj
|
|
24
|
+
backend = cls._prepare_backend(obj, hints)
|
|
25
|
+
if backend.name is None:
|
|
26
|
+
raise TypeError("This backend cannot be represented as a filename (no underlying filename)")
|
|
27
|
+
instance = super().__new__(cls, backend.name)
|
|
28
|
+
instance._backend = backend
|
|
29
|
+
return instance
|
|
30
|
+
|
|
31
|
+
def __init__(self, obj: TextstreamLike, **hints: Any) -> None:
|
|
32
|
+
super().__init__()
|
|
33
|
+
|
|
34
|
+
def unwrap(self) -> Any:
|
|
35
|
+
return unwrap(self._backend)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import pathlib
|
|
3
|
+
import urllib.request
|
|
4
|
+
|
|
5
|
+
from . import textstream_backend, textstream_view
|
|
6
|
+
|
|
7
|
+
type TextstreamLike = (
|
|
8
|
+
textstream_backend.TextstreamBackend
|
|
9
|
+
| textstream_view.TextstreamView
|
|
10
|
+
| io.TextIOBase
|
|
11
|
+
| io.StringIO
|
|
12
|
+
| str
|
|
13
|
+
| pathlib.Path
|
|
14
|
+
| urllib.request.Request
|
|
15
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import urllib.parse
|
|
3
|
+
import urllib.request
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from .compression import open_compressed, validate_compression
|
|
7
|
+
from .textstream_backend import TextstreamBackend
|
|
8
|
+
from .textstream_common import TextstreamCommon
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TextstreamRequest(TextstreamCommon, TextstreamBackend):
|
|
12
|
+
"""
|
|
13
|
+
Backend for streaming text fetched via a urllib.request.Request.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
_request: urllib.request.Request
|
|
17
|
+
_timeout: float | None
|
|
18
|
+
_encoding: str | None
|
|
19
|
+
_compression: str
|
|
20
|
+
_f: io.TextIOBase | None
|
|
21
|
+
_underlying: io.IOBase | None
|
|
22
|
+
_closed: bool
|
|
23
|
+
|
|
24
|
+
# mypy does not allow to type annotate __new__ as `Self | None` for some reason
|
|
25
|
+
def __new__(cls, request: urllib.request.Request, **hints: Any) -> Any:
|
|
26
|
+
if not isinstance(request, urllib.request.Request):
|
|
27
|
+
return None
|
|
28
|
+
if hints and hints.get("kind", "request") != "request":
|
|
29
|
+
return None
|
|
30
|
+
return super().__new__(cls)
|
|
31
|
+
|
|
32
|
+
def __init__(self, request: urllib.request.Request, **hints: Any) -> None:
|
|
33
|
+
self._request = request
|
|
34
|
+
self._timeout = hints.get("timeout")
|
|
35
|
+
self._encoding = hints.get("encoding")
|
|
36
|
+
self._compression = hints.get("compression", "auto")
|
|
37
|
+
validate_compression(self._compression)
|
|
38
|
+
self._f = None
|
|
39
|
+
self._underlying = None
|
|
40
|
+
self._closed = False
|
|
41
|
+
|
|
42
|
+
def _ensure_f(self) -> io.TextIOBase:
|
|
43
|
+
if self._closed:
|
|
44
|
+
raise ValueError("I/O operation on closed stream")
|
|
45
|
+
if self._f is None:
|
|
46
|
+
if self._timeout is None:
|
|
47
|
+
resp = urllib.request.urlopen(self._request)
|
|
48
|
+
else:
|
|
49
|
+
resp = urllib.request.urlopen(self._request, timeout=self._timeout)
|
|
50
|
+
encoding = self._encoding or resp.headers.get_content_charset() or "utf-8"
|
|
51
|
+
raw = cast(io.IOBase, resp)
|
|
52
|
+
name = urllib.parse.urlsplit(self._request.full_url).path
|
|
53
|
+
decompressed = open_compressed(raw, compression=self._compression, name=name)
|
|
54
|
+
self._underlying = raw if decompressed is not raw else None
|
|
55
|
+
self._f = io.TextIOWrapper(cast(io.BufferedReader, decompressed), encoding=encoding)
|
|
56
|
+
return self._f
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def name(self) -> str | None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def url(self) -> str:
|
|
64
|
+
return self._request.full_url
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def request(self) -> urllib.request.Request:
|
|
68
|
+
return self._request
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def closed(self) -> bool:
|
|
72
|
+
return self._closed
|