python-httpfile 0.0.1__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.
LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 ChenyangGao <https://github.com/ChenyangGao>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
httpfile/__init__.py ADDED
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env python3
2
+ # encoding: utf-8
3
+
4
+ __author__ = "ChenyangGao <https://chenyanggao.github.io>"
5
+ __version__ = (0, 0, 1)
6
+ __all__ = ["HTTPFileReader", "RequestsFileReader"]
7
+
8
+ import errno
9
+
10
+ from collections.abc import Callable, Iterator, Mapping
11
+ from functools import cached_property, partial
12
+ from http.client import HTTPResponse
13
+ from io import (
14
+ BufferedReader, RawIOBase, TextIOWrapper, UnsupportedOperation, DEFAULT_BUFFER_SIZE,
15
+ )
16
+ from os import fstat, stat, PathLike
17
+ from shutil import COPY_BUFSIZE # type: ignore
18
+ from typing import Any, BinaryIO, IO, Optional, Protocol, Self, TypeVar
19
+ from types import MappingProxyType
20
+ from warnings import warn
21
+
22
+ from aiohttp import ClientSession
23
+ from filewrap import bio_skip_bytes
24
+ from http_response import get_filename, get_length, get_range, get_total_length, is_chunked, is_range_request
25
+ from property import funcproperty
26
+ from requests import Session
27
+ from urlopen import urlopen
28
+
29
+
30
+ def get_filesize(file, /, dont_read: bool = True) -> int:
31
+ if isinstance(file, (bytes, str, PathLike)):
32
+ return stat(file).st_size
33
+ curpos = 0
34
+ try:
35
+ curpos = file.seek(0, 1)
36
+ seekable = True
37
+ except Exception:
38
+ seekable = False
39
+ if not seekable:
40
+ try:
41
+ curpos = file.tell()
42
+ except Exception:
43
+ pass
44
+ try:
45
+ return len(file) - curpos
46
+ except TypeError:
47
+ pass
48
+ if hasattr(file, "fileno"):
49
+ try:
50
+ return fstat(file.fileno()).st_size - curpos
51
+ except Exception:
52
+ pass
53
+ if hasattr(file, "headers"):
54
+ l = get_length(file)
55
+ if l is not None:
56
+ return l - curpos
57
+ if seekable:
58
+ try:
59
+ return file.seek(0, 2) - curpos
60
+ finally:
61
+ file.seek(curpos)
62
+ if dont_read:
63
+ return -1
64
+ total = 0
65
+ if hasattr(file, "readinto"):
66
+ readinto = file.readinto
67
+ buf = bytearray(COPY_BUFSIZE)
68
+ while (size := readinto(buf)):
69
+ total += size
70
+ elif hasattr(file, "read"):
71
+ read = file.read
72
+ while (chunk := read(COPY_BUFSIZE)):
73
+ total += len(chunk)
74
+ else:
75
+ return -1
76
+ return total
77
+
78
+
79
+ class HTTPFileReader(RawIOBase, BinaryIO):
80
+ url: str | Callable[[], str]
81
+ response: Any
82
+ length: int
83
+ chunked: bool
84
+ start: int
85
+ urlopen: Callable
86
+ headers: Mapping
87
+ seek_threshold: int
88
+ _seekable: bool
89
+
90
+ def __init__(
91
+ self,
92
+ /,
93
+ url: str | Callable[[], str],
94
+ headers: Optional[Mapping] = None,
95
+ start: int = 0,
96
+ # NOTE: If the offset of the forward seek is not higher than this value,
97
+ # it will be directly read and discarded, default to 1 MB
98
+ seek_threshold: int = 1 << 20,
99
+ urlopen: Callable[..., HTTPResponse] = urlopen,
100
+ ):
101
+ if headers:
102
+ headers = {**headers, "Accept-Encoding": "identity"}
103
+ else:
104
+ headers = {"Accept-Encoding": "identity"}
105
+ if start > 0:
106
+ headers["Range"] = f"bytes={start}-"
107
+ elif start < 0:
108
+ headers["Range"] = f"bytes={start}"
109
+ response = urlopen(url() if callable(url) else url, headers=headers)
110
+ if start:
111
+ rng = get_range(response)
112
+ if not rng:
113
+ raise OSError(errno.ESPIPE, "non-seekable")
114
+ start = rng[0]
115
+ self.__dict__.update(
116
+ url = url,
117
+ response = response,
118
+ length = get_total_length(response) or 0,
119
+ chunked = is_chunked(response),
120
+ start = start,
121
+ closed = False,
122
+ urlopen = urlopen,
123
+ headers = MappingProxyType(headers),
124
+ seek_threshold = max(seek_threshold, 0),
125
+ _seekable = is_range_request(response),
126
+ )
127
+
128
+ def __del__(self, /):
129
+ try:
130
+ self.close()
131
+ except:
132
+ pass
133
+
134
+ def __enter__(self, /):
135
+ return self
136
+
137
+ def __exit__(self, /, *exc_info):
138
+ self.close()
139
+
140
+ def __iter__(self, /):
141
+ return self
142
+
143
+ def __len__(self, /) -> int:
144
+ return self.length
145
+
146
+ def __next__(self, /) -> bytes:
147
+ line = self.readline()
148
+ if line:
149
+ return line
150
+ else:
151
+ raise StopIteration
152
+
153
+ def __repr__(self, /) -> str:
154
+ cls = type(self)
155
+ module = cls.__module__
156
+ name = cls.__qualname__
157
+ if module != "__main__":
158
+ name = module + "." + name
159
+ return f"{name}({self.url!r}, urlopen={self.urlopen!r}, headers={self.headers!r})"
160
+
161
+ def __setattr__(self, attr, val, /):
162
+ raise TypeError("can't set attribute")
163
+
164
+ def _add_start(self, delta: int, /):
165
+ self.__dict__["start"] += delta
166
+
167
+ def close(self, /):
168
+ self.response.close()
169
+ self.__dict__["closed"] = True
170
+
171
+ @funcproperty
172
+ def closed(self, /):
173
+ return self.__dict__["closed"]
174
+
175
+ @funcproperty
176
+ def file(self, /) -> BinaryIO:
177
+ return self.response
178
+
179
+ @funcproperty
180
+ def fileno(self, /):
181
+ return self.file.fileno()
182
+
183
+ def flush(self, /):
184
+ return self.file.flush()
185
+
186
+ def isatty(self, /) -> bool:
187
+ return False
188
+
189
+ @cached_property
190
+ def mode(self, /) -> str:
191
+ return "rb"
192
+
193
+ @cached_property
194
+ def name(self, /) -> str:
195
+ return get_filename(self.response)
196
+
197
+ def read(self, size: int = -1, /) -> bytes:
198
+ if self.closed:
199
+ raise ValueError("I/O operation on closed file.")
200
+ if size == 0 or not self.chunked and self.tell() >= self.length:
201
+ return b""
202
+ if self.file.closed:
203
+ self.reconnect()
204
+ if size is None or size < 0:
205
+ data = self.file.read()
206
+ else:
207
+ data = self.file.read(size)
208
+ if data:
209
+ self._add_start(len(data))
210
+ return data
211
+
212
+ def readable(self, /) -> bool:
213
+ return True
214
+
215
+ def readinto(self, buffer, /) -> int:
216
+ if self.closed:
217
+ raise ValueError("I/O operation on closed file.")
218
+ if not self.chunked and self.tell() >= self.length:
219
+ return 0
220
+ if self.file.closed:
221
+ self.reconnect()
222
+ size = self.file.readinto(buffer)
223
+ if size:
224
+ self._add_start(size)
225
+ return size
226
+
227
+ def readline(self, size: Optional[int] = -1, /) -> bytes:
228
+ if self.closed:
229
+ raise ValueError("I/O operation on closed file.")
230
+ if size == 0 or not self.chunked and self.tell() >= self.length:
231
+ return b""
232
+ if self.file.closed:
233
+ self.reconnect()
234
+ if size is None or size < 0:
235
+ data = self.file.readline()
236
+ else:
237
+ data = self.file.readline(size)
238
+ if data:
239
+ self._add_start(len(data))
240
+ return data
241
+
242
+ def readlines(self, hint: int = -1, /) -> list[bytes]:
243
+ if self.closed:
244
+ raise ValueError("I/O operation on closed file.")
245
+ if not self.chunked and self.tell() >= self.length:
246
+ return []
247
+ if self.file.closed:
248
+ self.reconnect()
249
+ ls = self.file.readlines(hint)
250
+ if ls:
251
+ self._add_start(sum(map(len, ls)))
252
+ return ls
253
+
254
+ def reconnect(self, /, start: Optional[int] = None) -> int:
255
+ if not self._seekable:
256
+ if start is None and self.tell() or start:
257
+ raise OSError(errno.EOPNOTSUPP, "Unsupport for reconnection of non-seekable streams.")
258
+ start = 0
259
+ if start is None:
260
+ start = self.tell()
261
+ elif start < 0:
262
+ start = self.length + start
263
+ if start < 0:
264
+ start = 0
265
+ if start >= self.length:
266
+ self.__dict__.update(start=start)
267
+ return start
268
+ self.response.close()
269
+ url = self.url
270
+ response = self.urlopen(
271
+ url() if callable(url) else url,
272
+ headers={**self.headers, "Range": f"bytes={start}-"}
273
+ )
274
+ length_new = get_total_length(response)
275
+ if self.length != length_new:
276
+ raise OSError(errno.EIO, f"file size changed: {self.length} -> {length_new}")
277
+ self.__dict__.update(
278
+ response=response,
279
+ start=start,
280
+ closed=False,
281
+ )
282
+ return start
283
+
284
+ def seek(self, pos: int, whence: int = 0, /) -> int:
285
+ if self.closed:
286
+ raise ValueError("I/O operation on closed file.")
287
+ if not self._seekable:
288
+ raise OSError(errno.EINVAL, "not a seekable stream")
289
+ if whence == 0:
290
+ if pos < 0:
291
+ raise OSError(errno.EINVAL, f"negative seek start: {pos!r}")
292
+ old_pos = self.tell()
293
+ if old_pos == pos:
294
+ return pos
295
+ if pos > old_pos and pos - old_pos <= self.seek_threshold:
296
+ bio_skip_bytes(self, pos - old_pos)
297
+ else:
298
+ self.reconnect(pos)
299
+ return pos
300
+ elif whence == 1:
301
+ if pos == 0:
302
+ return self.tell()
303
+ return self.seek(self.tell() + pos)
304
+ elif whence == 2:
305
+ return self.seek(self.length + pos)
306
+ else:
307
+ raise OSError(errno.EINVAL, f"whence value unsupported: {whence!r}")
308
+
309
+ def seekable(self, /) -> bool:
310
+ return self._seekable
311
+
312
+ def tell(self, /) -> int:
313
+ return self.start
314
+
315
+ def truncate(self, size: Optional[int] = None, /):
316
+ raise UnsupportedOperation(errno.ENOTSUP, "truncate")
317
+
318
+ def writable(self, /) -> bool:
319
+ return False
320
+
321
+ def write(self, b, /) -> int:
322
+ raise UnsupportedOperation(errno.ENOTSUP, "write")
323
+
324
+ def writelines(self, lines, /):
325
+ raise UnsupportedOperation(errno.ENOTSUP, "writelines")
326
+
327
+ def wrap(
328
+ self,
329
+ /,
330
+ text_mode: bool = False,
331
+ buffering: Optional[int] = None,
332
+ encoding: Optional[str] = None,
333
+ errors: Optional[str] = None,
334
+ newline: Optional[str] = None,
335
+ ) -> Self | IO:
336
+ if buffering is None:
337
+ if text_mode:
338
+ buffering = DEFAULT_BUFFER_SIZE
339
+ else:
340
+ buffering = 0
341
+ if buffering == 0:
342
+ if text_mode:
343
+ raise OSError(errno.EINVAL, "can't have unbuffered text I/O")
344
+ return self
345
+ line_buffering = False
346
+ buffer_size: int
347
+ if buffering < 0:
348
+ buffer_size = DEFAULT_BUFFER_SIZE
349
+ elif buffering == 1:
350
+ if not text_mode:
351
+ warn("line buffering (buffering=1) isn't supported in binary mode, "
352
+ "the default buffer size will be used", RuntimeWarning)
353
+ buffer_size = DEFAULT_BUFFER_SIZE
354
+ line_buffering = True
355
+ else:
356
+ buffer_size = buffering
357
+ raw = self
358
+ buffer = BufferedReader(raw, buffer_size)
359
+ if text_mode:
360
+ return TextIOWrapper(
361
+ buffer,
362
+ encoding=encoding,
363
+ errors=errors,
364
+ newline=newline,
365
+ line_buffering=line_buffering,
366
+ )
367
+ else:
368
+ return buffer
369
+
370
+
371
+ class RequestsFileReader(HTTPFileReader):
372
+
373
+ def __init__(
374
+ self,
375
+ /,
376
+ url: str | Callable[[], str],
377
+ headers: Optional[Mapping] = None,
378
+ start: int = 0,
379
+ seek_threshold: int = 1 << 20,
380
+ urlopen: Callable = Session().get,
381
+ ):
382
+ def urlopen_wrapper(url: str, headers: Optional[Mapping] = headers):
383
+ resp = urlopen(url, headers=headers, stream=True)
384
+ resp.raise_for_status()
385
+ return resp
386
+ super().__init__(
387
+ url,
388
+ headers=headers,
389
+ start=start,
390
+ seek_threshold=seek_threshold,
391
+ urlopen=urlopen_wrapper,
392
+ )
393
+
394
+ def _add_start(self, delta: int, /):
395
+ pass
396
+
397
+ @funcproperty
398
+ def file(self, /) -> BinaryIO:
399
+ return self.response.raw
400
+
401
+ def tell(self, /) -> int:
402
+ start = self.start
403
+ if start >= self.length:
404
+ return start
405
+ return start + self.file.tell()
406
+
407
+
408
+ # TODO: 支持异步文件,使用 aiohttp,参考 aiofiles 的接口实现
409
+ # TODO: 设计实现一个 HTTPFileWriter,用于实现上传,关闭后视为上传完成
410
+
httpfile/py.typed ADDED
File without changes
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 ChenyangGao <https://github.com/ChenyangGao>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.1
2
+ Name: python-httpfile
3
+ Version: 0.0.1
4
+ Summary: Python httpfile classes.
5
+ Home-page: https://github.com/ChenyangGao/web-mount-packs/tree/main/python-module/python-httpfile
6
+ License: MIT
7
+ Keywords: http,file,wrapper
8
+ Author: ChenyangGao
9
+ Author-email: wosiwujm@gmail.com
10
+ Requires-Python: >=3.10,<4.0
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Topic :: Software Development
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Dist: aiohttp
25
+ Requires-Dist: http_response
26
+ Requires-Dist: python-filewrap
27
+ Requires-Dist: python-property
28
+ Requires-Dist: python-urlopen
29
+ Requires-Dist: requests
30
+ Project-URL: Repository, https://github.com/ChenyangGao/web-mount-packs/tree/main/python-module/python-httpfile
31
+ Description-Content-Type: text/markdown
32
+
33
+ # Python httpfile classes.
34
+
35
+ ## Installation
36
+
37
+ You can install from [pypi](https://pypi.org/project/python-httpfile/)
38
+
39
+ ```console
40
+ pip install -U python-httpfile
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```python
46
+ import httpfile
47
+ ```
48
+
@@ -0,0 +1,7 @@
1
+ LICENSE,sha256=o5242_N2TgDsWwFhPn7yr8YJNF7XsJM5NxUMtcT97bc,1100
2
+ httpfile/__init__.py,sha256=KYXYVFaUhu06R3cBEb9FPGmPuLrMVUFWKPx5aovija4,12280
3
+ httpfile/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ python_httpfile-0.0.1.dist-info/LICENSE,sha256=o5242_N2TgDsWwFhPn7yr8YJNF7XsJM5NxUMtcT97bc,1100
5
+ python_httpfile-0.0.1.dist-info/METADATA,sha256=ZWLh_Fh4q5FjF2P_2B2Szl2KjYs16XtzCbzjLSxTgIs,1508
6
+ python_httpfile-0.0.1.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
7
+ python_httpfile-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.8.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any