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.
Files changed (49) hide show
  1. httk/core/__init__.py +112 -0
  2. httk/core/_discover.py +35 -0
  3. httk/core/_plugins.py +95 -0
  4. httk/core/dataloader.py +263 -0
  5. httk/core/datastream/__init__.py +80 -0
  6. httk/core/datastream/bytestream_api.py +33 -0
  7. httk/core/datastream/bytestream_backend.py +12 -0
  8. httk/core/datastream/bytestream_bytes.py +52 -0
  9. httk/core/datastream/bytestream_bytes_view.py +31 -0
  10. httk/core/datastream/bytestream_common.py +38 -0
  11. httk/core/datastream/bytestream_file.py +53 -0
  12. httk/core/datastream/bytestream_file_view.py +189 -0
  13. httk/core/datastream/bytestream_filename.py +53 -0
  14. httk/core/datastream/bytestream_filename_view.py +34 -0
  15. httk/core/datastream/bytestream_like.py +17 -0
  16. httk/core/datastream/bytestream_request.py +69 -0
  17. httk/core/datastream/bytestream_request_view.py +44 -0
  18. httk/core/datastream/bytestream_url.py +74 -0
  19. httk/core/datastream/bytestream_url_view.py +35 -0
  20. httk/core/datastream/bytestream_view.py +16 -0
  21. httk/core/datastream/compression.py +183 -0
  22. httk/core/datastream/textstream_api.py +33 -0
  23. httk/core/datastream/textstream_backend.py +12 -0
  24. httk/core/datastream/textstream_common.py +37 -0
  25. httk/core/datastream/textstream_file.py +45 -0
  26. httk/core/datastream/textstream_file_view.py +201 -0
  27. httk/core/datastream/textstream_filename.py +55 -0
  28. httk/core/datastream/textstream_filename_view.py +35 -0
  29. httk/core/datastream/textstream_like.py +15 -0
  30. httk/core/datastream/textstream_request.py +72 -0
  31. httk/core/datastream/textstream_request_view.py +44 -0
  32. httk/core/datastream/textstream_string.py +47 -0
  33. httk/core/datastream/textstream_string_view.py +31 -0
  34. httk/core/datastream/textstream_url.py +77 -0
  35. httk/core/datastream/textstream_url_view.py +35 -0
  36. httk/core/datastream/textstream_view.py +16 -0
  37. httk/core/loading.py +29 -0
  38. httk/core/py.typed +0 -0
  39. httk/core/register.py +30 -0
  40. httk/core/views/__init__.py +5 -0
  41. httk/core/views/backend.py +49 -0
  42. httk/core/views/unwrapping.py +12 -0
  43. httk/core/views/view.py +56 -0
  44. httk/handlers/core/__init__.py +0 -0
  45. httk_core-2.0.0.dist-info/METADATA +76 -0
  46. httk_core-2.0.0.dist-info/RECORD +49 -0
  47. httk_core-2.0.0.dist-info/WHEEL +5 -0
  48. httk_core-2.0.0.dist-info/licenses/LICENSE +661 -0
  49. 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