python-httpfile 0.0.2.1__py3-none-any.whl → 0.0.4__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.
httpfile/__init__.py CHANGED
@@ -1,30 +1,40 @@
1
1
  #!/usr/bin/env python3
2
2
  # encoding: utf-8
3
3
 
4
+ from __future__ import annotations
5
+
4
6
  __author__ = "ChenyangGao <https://chenyanggao.github.io>"
5
- __version__ = (0, 0, 2)
7
+ __version__ = (0, 0, 4)
6
8
  __all__ = ["HTTPFileReader", "AsyncHTTPFileReader"]
7
9
 
8
10
  import errno
9
11
 
10
- from collections.abc import Callable, Iterator, Mapping
12
+ from collections.abc import Awaitable, Callable, Mapping
11
13
  from functools import cached_property, partial
12
- from http.client import HTTPResponse
14
+ from importlib.util import find_spec
15
+ from inspect import isawaitable
13
16
  from io import (
14
17
  BufferedReader, RawIOBase, TextIOWrapper, UnsupportedOperation, DEFAULT_BUFFER_SIZE,
15
18
  )
16
19
  from os import fstat, stat, PathLike
17
20
  from shutil import COPY_BUFSIZE # type: ignore
18
- from typing import Any, BinaryIO, IO, Optional, Protocol, Self, TypeVar
21
+ from sys import exc_info
22
+ from typing import cast, overload, Any, BinaryIO, Literal, Self
19
23
  from types import MappingProxyType
20
24
  from warnings import warn
21
25
 
26
+ from asynctools import ensure_async, run_async
27
+ from filewrap import AsyncBufferedReader, AsyncTextIOWrapper
22
28
  from http_response import get_filename, get_length, get_range, get_total_length, is_chunked, is_range_request
23
29
  from property import funcproperty
24
30
  from urlopen import urlopen
25
31
 
26
32
 
27
- def get_filesize(file, /, dont_read: bool = True) -> int:
33
+ def get_filesize(
34
+ file,
35
+ /,
36
+ dont_read: bool = True,
37
+ ) -> int:
28
38
  if isinstance(file, (bytes, str, PathLike)):
29
39
  return stat(file).st_size
30
40
  curpos = 0
@@ -88,12 +98,12 @@ class HTTPFileReader(RawIOBase, BinaryIO):
88
98
  self,
89
99
  /,
90
100
  url: str | Callable[[], str],
91
- headers: Optional[Mapping] = None,
101
+ headers: None | Mapping = None,
92
102
  start: int = 0,
93
103
  # NOTE: If the offset of the forward seek is not higher than this value,
94
104
  # it will be directly read and discarded, default to 1 MB
95
105
  seek_threshold: int = 1 << 20,
96
- urlopen: Callable[..., HTTPResponse] = urlopen,
106
+ urlopen = urlopen,
97
107
  ):
98
108
  if headers:
99
109
  headers = {**headers, "Accept-Encoding": "identity"}
@@ -103,6 +113,8 @@ class HTTPFileReader(RawIOBase, BinaryIO):
103
113
  headers["Range"] = f"bytes={start}-"
104
114
  elif start < 0:
105
115
  headers["Range"] = f"bytes={start}"
116
+ if urlopen is None:
117
+ urlopen = globals()["urlopen"]
106
118
  if callable(url):
107
119
  geturl = url
108
120
  def url():
@@ -146,7 +158,7 @@ class HTTPFileReader(RawIOBase, BinaryIO):
146
158
  def __exit__(self, /, *exc_info):
147
159
  self.close()
148
160
 
149
- def __iter__(self, /):
161
+ def __iter__(self, /) -> Self:
150
162
  return self
151
163
 
152
164
  def __len__(self, /) -> int:
@@ -165,23 +177,36 @@ class HTTPFileReader(RawIOBase, BinaryIO):
165
177
  name = cls.__qualname__
166
178
  if module != "__main__":
167
179
  name = module + "." + name
168
- return f"{name}({self.url!r}, urlopen={self.urlopen!r}, headers={self.headers!r})"
180
+ return f"{name}(url={self.url!r}, headers={self.headers!r}, start={self.tell()}, seek_threshold={self.seek_threshold}, urlopen={self.urlopen!r})"
169
181
 
170
182
  def __setattr__(self, attr, val, /):
171
183
  raise TypeError("can't set attribute")
172
184
 
173
185
  def _add_start(self, delta: int, /):
174
- self.__dict__["start"] += delta
186
+ if not self.file_can_tell:
187
+ self.__dict__["start"] += delta
188
+
189
+ @cached_property
190
+ def file_can_tell(self, /) -> bool:
191
+ try:
192
+ self.file.tell()
193
+ return True
194
+ except (AttributeError, TypeError, OSError):
195
+ return False
196
+
197
+ @funcproperty
198
+ def file_closed(self, /) -> bool:
199
+ return self.file.closed
200
+
201
+ @property
202
+ def closed(self, /) -> bool:
203
+ return self.__dict__["closed"]
175
204
 
176
205
  def close(self, /):
177
206
  if not self.closed:
178
207
  self.response.close()
179
208
  self.__dict__["closed"] = True
180
209
 
181
- @funcproperty
182
- def closed(self, /):
183
- return self.__dict__["closed"]
184
-
185
210
  @funcproperty
186
211
  def file(self, /) -> BinaryIO:
187
212
  return self.response
@@ -208,7 +233,7 @@ class HTTPFileReader(RawIOBase, BinaryIO):
208
233
  raise ValueError("I/O operation on closed file.")
209
234
  if size == 0 or not self.chunked and self.tell() >= self.length:
210
235
  return b""
211
- if self.file.closed:
236
+ if self.file_closed:
212
237
  self.reconnect()
213
238
  if size is None or size < 0:
214
239
  data = self.file.read()
@@ -226,19 +251,19 @@ class HTTPFileReader(RawIOBase, BinaryIO):
226
251
  raise ValueError("I/O operation on closed file.")
227
252
  if not buffer or not self.chunked and self.tell() >= self.length:
228
253
  return 0
229
- if self.file.closed:
254
+ if self.file_closed:
230
255
  self.reconnect()
231
256
  size = self.file.readinto(buffer)
232
257
  if size:
233
258
  self._add_start(size)
234
259
  return size
235
260
 
236
- def readline(self, size: Optional[int] = -1, /) -> bytes:
261
+ def readline(self, size: None | int = -1, /) -> bytes:
237
262
  if self.closed:
238
263
  raise ValueError("I/O operation on closed file.")
239
264
  if size == 0 or not self.chunked and self.tell() >= self.length:
240
265
  return b""
241
- if self.file.closed:
266
+ if self.file_closed:
242
267
  self.reconnect()
243
268
  if size is None or size < 0:
244
269
  data = self.file.readline()
@@ -253,14 +278,14 @@ class HTTPFileReader(RawIOBase, BinaryIO):
253
278
  raise ValueError("I/O operation on closed file.")
254
279
  if not self.chunked and self.tell() >= self.length:
255
280
  return []
256
- if self.file.closed:
281
+ if self.file_closed:
257
282
  self.reconnect()
258
283
  ls = self.file.readlines(hint)
259
284
  if ls:
260
285
  self._add_start(sum(map(len, ls)))
261
286
  return ls
262
287
 
263
- def reconnect(self, /, start: Optional[int] = None) -> int:
288
+ def reconnect(self, /, start: None | int = None) -> int:
264
289
  if not self._seekable:
265
290
  if start is None and self.tell() or start:
266
291
  raise OSError(errno.EOPNOTSUPP, "Unsupport for reconnection of non-seekable streams.")
@@ -327,9 +352,15 @@ class HTTPFileReader(RawIOBase, BinaryIO):
327
352
  return self._seekable
328
353
 
329
354
  def tell(self, /) -> int:
330
- return self.start
355
+ if self.file_can_tell:
356
+ start = self.start
357
+ if start >= self.length:
358
+ return start
359
+ return start + self.file.tell()
360
+ else:
361
+ return self.start
331
362
 
332
- def truncate(self, size: Optional[int] = None, /):
363
+ def truncate(self, size: None | int = None, /):
333
364
  raise UnsupportedOperation(errno.ENOTSUP, "truncate")
334
365
 
335
366
  def writable(self, /) -> bool:
@@ -341,15 +372,109 @@ class HTTPFileReader(RawIOBase, BinaryIO):
341
372
  def writelines(self, lines, /):
342
373
  raise UnsupportedOperation(errno.ENOTSUP, "writelines")
343
374
 
375
+ @overload
376
+ @classmethod
377
+ def open(
378
+ cls,
379
+ /,
380
+ url: str | Callable[[], str],
381
+ mode: Literal["br", "rb"],
382
+ *,
383
+ buffering: None | int = None,
384
+ encoding: None | str = None,
385
+ errors: None | str = None,
386
+ newline: None | str = None,
387
+ headers: None | Mapping = None,
388
+ start: int = 0,
389
+ seek_threshold: int = 1 << 20,
390
+ urlopen = None,
391
+ ) -> Self | BufferedReader:
392
+ ...
393
+ @overload
394
+ @classmethod
395
+ def open(
396
+ cls,
397
+ /,
398
+ url: str | Callable[[], str],
399
+ mode: Literal["r", "rt", "tr"] = "r",
400
+ *,
401
+ buffering: None | int = None,
402
+ encoding: None | str = None,
403
+ errors: None | str = None,
404
+ newline: None | str = None,
405
+ headers: None | Mapping = None,
406
+ start: int = 0,
407
+ seek_threshold: int = 1 << 20,
408
+ urlopen = None,
409
+ ) -> TextIOWrapper:
410
+ ...
411
+ @classmethod
412
+ def open(
413
+ cls,
414
+ /,
415
+ url: str | Callable[[], str],
416
+ mode: Literal["r", "rt", "tr", "br", "rb"] = "r",
417
+ *,
418
+ buffering: None | int = None,
419
+ encoding: None | str = None,
420
+ errors: None | str = None,
421
+ newline: None | str = None,
422
+ headers: None | Mapping = None,
423
+ start: int = 0,
424
+ seek_threshold: int = 1 << 20,
425
+ urlopen = None,
426
+ ) -> Self | BufferedReader | TextIOWrapper:
427
+ file = cls(
428
+ url=url,
429
+ headers=headers,
430
+ start=start,
431
+ seek_threshold=seek_threshold,
432
+ urlopen=urlopen,
433
+ )
434
+ if mode not in ("r", "rt", "tr", "rb", "br"):
435
+ raise OSError(errno.EINVAL, f"invalid (or unsupported) mode: {mode!r}")
436
+ return file.wrap(
437
+ text_mode="b" not in mode, # type: ignore
438
+ buffering=buffering,
439
+ encoding=encoding,
440
+ errors=errors,
441
+ newline=newline,
442
+ )
443
+
444
+ @overload
344
445
  def wrap(
345
446
  self,
346
447
  /,
347
- text_mode: bool = False,
348
- buffering: Optional[int] = None,
349
- encoding: Optional[str] = None,
350
- errors: Optional[str] = None,
351
- newline: Optional[str] = None,
352
- ) -> Self | IO:
448
+ text_mode: Literal[False] = False,
449
+ *,
450
+ buffering: None | int = None,
451
+ encoding: None | str = None,
452
+ errors: None | str = None,
453
+ newline: None | str = None,
454
+ ) -> Self | BufferedReader:
455
+ ...
456
+ @overload
457
+ def wrap(
458
+ self,
459
+ /,
460
+ text_mode: Literal[True],
461
+ *,
462
+ buffering: None | int = None,
463
+ encoding: None | str = None,
464
+ errors: None | str = None,
465
+ newline: None | str = None,
466
+ ) -> TextIOWrapper:
467
+ ...
468
+ def wrap(
469
+ self,
470
+ /,
471
+ text_mode: Literal[False, True] = False,
472
+ *,
473
+ buffering: None | int = None,
474
+ encoding: None | str = None,
475
+ errors: None | str = None,
476
+ newline: None | str = None,
477
+ ) -> Self | BufferedReader | TextIOWrapper:
353
478
  if buffering is None:
354
479
  if text_mode:
355
480
  buffering = DEFAULT_BUFFER_SIZE
@@ -385,8 +510,443 @@ class HTTPFileReader(RawIOBase, BinaryIO):
385
510
  return buffer
386
511
 
387
512
 
388
- try:
389
- from requests import Session
513
+ class AsyncHTTPFileReader(HTTPFileReader):
514
+ url: str | Callable[[], Awaitable[str]] # type: ignore
515
+
516
+ def __init__(
517
+ self,
518
+ /,
519
+ url: str | Callable[[], str] | Callable[[], Awaitable[str]],
520
+ headers: None | Mapping = None,
521
+ start: int = 0,
522
+ seek_threshold: int = 1 << 20,
523
+ urlopen = urlopen,
524
+ ):
525
+ run_async(self.__ainit__(
526
+ url=url,
527
+ headers=headers,
528
+ start=start,
529
+ seek_threshold=seek_threshold,
530
+ urlopen=urlopen,
531
+ ))
532
+
533
+ async def __aenter__(self, /) -> Self:
534
+ return self
535
+
536
+ async def __aexit__(self, /, *exc_info):
537
+ await self.aclose()
538
+
539
+ def __aiter__(self, /) -> Self:
540
+ return self
541
+
542
+ async def __anext__(self, /) -> bytes:
543
+ if line := await self.readline():
544
+ return line
545
+ else:
546
+ raise StopAsyncIteration
547
+
548
+ async def __ainit__(
549
+ self,
550
+ /,
551
+ url: str | Callable[[], str] | Callable[[], Awaitable[str]],
552
+ headers: None | Mapping = None,
553
+ start: int = 0,
554
+ seek_threshold: int = 1 << 20,
555
+ urlopen = None,
556
+ ):
557
+ if headers:
558
+ headers = {**headers, "Accept-Encoding": "identity"}
559
+ else:
560
+ headers = {"Accept-Encoding": "identity"}
561
+ if start > 0:
562
+ headers["Range"] = f"bytes={start}-"
563
+ elif start < 0:
564
+ headers["Range"] = f"bytes={start}"
565
+ if urlopen is None:
566
+ urlopen = globals()["urlopen"]
567
+ if callable(url):
568
+ geturl = url
569
+ async def url() -> str:
570
+ url = geturl()
571
+ if isawaitable(url):
572
+ url = await url
573
+ headers_extra = getattr(url, "headers")
574
+ if headers_extra:
575
+ headers.update(headers_extra)
576
+ return cast(str, url)
577
+ elif hasattr(url, "headers"):
578
+ headers_extra = getattr(url, "headers")
579
+ if headers_extra:
580
+ headers.update(headers_extra)
581
+ urlopen = ensure_async(urlopen, threaded=True)
582
+ response = await urlopen((await url()) if callable(url) else url, headers=headers)
583
+ if start:
584
+ rng = get_range(response)
585
+ if not rng:
586
+ raise OSError(errno.ESPIPE, "non-seekable")
587
+ start = rng[0]
588
+ self.__dict__.update(
589
+ url = url,
590
+ response = response,
591
+ length = get_total_length(response) or 0,
592
+ chunked = is_chunked(response),
593
+ start = start,
594
+ closed = False,
595
+ urlopen = urlopen,
596
+ headers = MappingProxyType(headers),
597
+ seek_threshold = max(seek_threshold, 0),
598
+ _seekable = is_range_request(response),
599
+ )
600
+
601
+ async def aclose(self, /):
602
+ if not self.closed:
603
+ self.__dict__["closed"] = True
604
+ await self.close_response()
605
+
606
+ def close(self, /):
607
+ if not self.closed:
608
+ self.__dict__["closed"] = True
609
+ try:
610
+ ret = self.response.aclose()
611
+ except (AttributeError, TypeError):
612
+ ret = self.response.close()
613
+ if isawaitable(ret):
614
+ run_async(ret)
615
+
616
+ async def close_response(self, /):
617
+ try:
618
+ ret = self.response.aclose()
619
+ except (AttributeError, TypeError):
620
+ ret = self.response.close()
621
+ if isawaitable(ret):
622
+ await ret
623
+
624
+ async def flush(self, /):
625
+ return await ensure_async(self.file.flush, threaded=True)()
626
+
627
+ async def read(self, size: int = -1, /) -> bytes: # type: ignore
628
+ if self.closed:
629
+ raise ValueError("I/O operation on closed file.")
630
+ if size == 0 or not self.chunked and self.tell() >= self.length:
631
+ return b""
632
+ if self.file_closed:
633
+ await self.reconnect()
634
+ if size is None or size < 0:
635
+ data = await ensure_async(self.file.read, threaded=True)()
636
+ else:
637
+ data = await ensure_async(self.file.read, threaded=True)(size)
638
+ if data:
639
+ self._add_start(len(data))
640
+ return data
641
+
642
+ async def readinto(self, buffer, /) -> int: # type: ignore
643
+ if self.closed:
644
+ raise ValueError("I/O operation on closed file.")
645
+ if not buffer or not self.chunked and self.tell() >= self.length:
646
+ return 0
647
+ if self.file_closed:
648
+ await self.reconnect()
649
+ size = await ensure_async(self.file.readinto, threaded=True)(buffer)
650
+ if size:
651
+ self._add_start(size)
652
+ return size
653
+
654
+ async def readline(self, size: None | int = -1, /) -> bytes: # type: ignore
655
+ if self.closed:
656
+ raise ValueError("I/O operation on closed file.")
657
+ if size == 0 or not self.chunked and self.tell() >= self.length:
658
+ return b""
659
+ if self.file_closed:
660
+ await self.reconnect()
661
+ if size is None or size < 0:
662
+ data = await ensure_async(self.file.readline, threaded=True)()
663
+ else:
664
+ data = await ensure_async(self.file.readline, threaded=True)(size)
665
+ if data:
666
+ self._add_start(len(data))
667
+ return data
668
+
669
+ async def readlines(self, hint: int = -1, /) -> list[bytes]: # type: ignore
670
+ if self.closed:
671
+ raise ValueError("I/O operation on closed file.")
672
+ if not self.chunked and self.tell() >= self.length:
673
+ return []
674
+ if self.file_closed:
675
+ await self.reconnect()
676
+ ls = await ensure_async(self.file.readlines, threaded=True)(hint)
677
+ if ls:
678
+ self._add_start(sum(map(len, ls)))
679
+ return ls
680
+
681
+ async def reconnect(self, /, start: None | int = None) -> int: # type: ignore
682
+ if not self._seekable:
683
+ if start is None and self.tell() or start:
684
+ raise OSError(errno.EOPNOTSUPP, "Unsupport for reconnection of non-seekable streams.")
685
+ start = 0
686
+ if start is None:
687
+ start = self.tell()
688
+ elif start < 0:
689
+ start = self.length + start
690
+ if start < 0:
691
+ start = 0
692
+ if start >= self.length:
693
+ self.__dict__.update(start=start)
694
+ return start
695
+ await self.close_response()
696
+ url = self.url
697
+ response = await self.urlopen(
698
+ (await url()) if callable(url) else url,
699
+ headers={**self.headers, "Range": f"bytes={start}-"}
700
+ )
701
+ length_new = get_total_length(response)
702
+ if self.length != length_new:
703
+ raise OSError(errno.EIO, f"file size changed: {self.length} -> {length_new}")
704
+ self.__dict__.update(
705
+ response=response,
706
+ start=start,
707
+ closed=False,
708
+ )
709
+ return start
710
+
711
+ async def seek(self, pos: int, whence: int = 0, /) -> int: # type: ignore
712
+ if self.closed:
713
+ raise ValueError("I/O operation on closed file.")
714
+ if not self._seekable:
715
+ raise OSError(errno.EINVAL, "not a seekable stream")
716
+ if whence == 0:
717
+ if pos < 0:
718
+ raise OSError(errno.EINVAL, f"negative seek start: {pos!r}")
719
+ old_pos = self.tell()
720
+ if old_pos == pos:
721
+ return pos
722
+ if pos > old_pos and (size := pos - old_pos) <= self.seek_threshold:
723
+ if size <= COPY_BUFSIZE:
724
+ await self.read(size)
725
+ else:
726
+ buf = bytearray(COPY_BUFSIZE)
727
+ readinto = self.readinto
728
+ while size > COPY_BUFSIZE:
729
+ await readinto(buf)
730
+ size -= COPY_BUFSIZE
731
+ await self.read(size)
732
+ else:
733
+ await self.reconnect(pos)
734
+ return pos
735
+ elif whence == 1:
736
+ if pos == 0:
737
+ return self.tell()
738
+ return await self.seek(self.tell() + pos)
739
+ elif whence == 2:
740
+ return await self.seek(self.length + pos)
741
+ else:
742
+ raise OSError(errno.EINVAL, f"whence value unsupported: {whence!r}")
743
+
744
+ @classmethod
745
+ async def new(
746
+ cls,
747
+ /,
748
+ url: str | Callable[[], str],
749
+ headers: None | Mapping = None,
750
+ start: int = 0,
751
+ seek_threshold: int = 1 << 20,
752
+ urlopen = None,
753
+ ) -> Self: # type: ignore
754
+ self = cls.__new__(cls)
755
+ await self.__ainit__(
756
+ url=url,
757
+ headers=headers,
758
+ start=start,
759
+ seek_threshold=seek_threshold,
760
+ urlopen=urlopen,
761
+ )
762
+ return self
763
+
764
+ @overload # type: ignore
765
+ @classmethod
766
+ async def open(
767
+ cls,
768
+ /,
769
+ url: str | Callable[[], str],
770
+ mode: Literal["br", "rb"],
771
+ *,
772
+ buffering: None | int = None,
773
+ encoding: None | str = None,
774
+ errors: None | str = None,
775
+ newline: None | str = None,
776
+ headers: None | Mapping = None,
777
+ start: int = 0,
778
+ seek_threshold: int = 1 << 20,
779
+ urlopen = None,
780
+ ) -> Self | AsyncBufferedReader:
781
+ ...
782
+ @overload
783
+ @classmethod
784
+ async def open(
785
+ cls,
786
+ /,
787
+ url: str | Callable[[], str],
788
+ mode: Literal["r", "rt", "tr"] = "r",
789
+ *,
790
+ buffering: None | int = None,
791
+ encoding: None | str = None,
792
+ errors: None | str = None,
793
+ newline: None | str = None,
794
+ headers: None | Mapping = None,
795
+ start: int = 0,
796
+ seek_threshold: int = 1 << 20,
797
+ urlopen = None,
798
+ ) -> AsyncTextIOWrapper:
799
+ ...
800
+ @classmethod
801
+ async def open(
802
+ cls,
803
+ /,
804
+ url: str | Callable[[], str],
805
+ mode: Literal["r", "rt", "tr", "br", "rb"] = "r",
806
+ buffering: None | int = None,
807
+ encoding: None | str = None,
808
+ errors: None | str = None,
809
+ newline: None | str = None,
810
+ headers: None | Mapping = None,
811
+ start: int = 0,
812
+ seek_threshold: int = 1 << 20,
813
+ urlopen = None,
814
+ ) -> Self | AsyncBufferedReader | AsyncTextIOWrapper:
815
+ file = await cls.new(
816
+ url=url,
817
+ headers=headers,
818
+ start=start,
819
+ seek_threshold=seek_threshold,
820
+ urlopen=urlopen,
821
+ )
822
+ if mode not in ("r", "rt", "tr", "rb", "br"):
823
+ raise OSError(errno.EINVAL, f"invalid (or unsupported) mode: {mode!r}")
824
+ return file.wrap(
825
+ text_mode="b" not in mode, # type: ignore
826
+ buffering=buffering,
827
+ encoding=encoding,
828
+ errors=errors,
829
+ newline=newline,
830
+ )
831
+
832
+ @overload
833
+ def wrap(
834
+ self,
835
+ /,
836
+ text_mode: Literal[False] = False,
837
+ *,
838
+ buffering: None | int = None,
839
+ encoding: None | str = None,
840
+ errors: None | str = None,
841
+ newline: None | str = None,
842
+ ) -> Self | AsyncBufferedReader:
843
+ ...
844
+ @overload
845
+ def wrap(
846
+ self,
847
+ /,
848
+ text_mode: Literal[True],
849
+ *,
850
+ buffering: None | int = None,
851
+ encoding: None | str = None,
852
+ errors: None | str = None,
853
+ newline: None | str = None,
854
+ ) -> AsyncTextIOWrapper:
855
+ ...
856
+ def wrap(
857
+ self,
858
+ /,
859
+ text_mode: bool = False,
860
+ *,
861
+ buffering: None | int = None,
862
+ encoding: None | str = None,
863
+ errors: None | str = None,
864
+ newline: None | str = None,
865
+ ) -> Self | AsyncBufferedReader | AsyncTextIOWrapper:
866
+ if buffering is None:
867
+ if text_mode:
868
+ buffering = DEFAULT_BUFFER_SIZE
869
+ else:
870
+ buffering = 0
871
+ if buffering == 0:
872
+ if text_mode:
873
+ raise OSError(errno.EINVAL, "can't have unbuffered text I/O")
874
+ return self
875
+ line_buffering = False
876
+ buffer_size: int
877
+ if buffering < 0:
878
+ buffer_size = DEFAULT_BUFFER_SIZE
879
+ elif buffering == 1:
880
+ if not text_mode:
881
+ warn("line buffering (buffering=1) isn't supported in binary mode, "
882
+ "the default buffer size will be used", RuntimeWarning)
883
+ buffer_size = DEFAULT_BUFFER_SIZE
884
+ line_buffering = True
885
+ else:
886
+ buffer_size = buffering
887
+ raw = self
888
+ buffer = AsyncBufferedReader(raw, buffer_size)
889
+ if text_mode:
890
+ return AsyncTextIOWrapper(
891
+ buffer,
892
+ encoding=encoding,
893
+ errors=errors,
894
+ newline=newline,
895
+ line_buffering=line_buffering,
896
+ )
897
+ else:
898
+ return buffer
899
+
900
+
901
+ if find_spec("urllib3"):
902
+ from urllib.error import HTTPError
903
+
904
+ _urllib3_urlopen = None
905
+
906
+ class Urllib3FileReader(HTTPFileReader):
907
+
908
+ def __init__(
909
+ self,
910
+ /,
911
+ url: str | Callable[[], str],
912
+ headers: None | Mapping = None,
913
+ start: int = 0,
914
+ seek_threshold: int = 1 << 20,
915
+ urlopen = None,
916
+ ):
917
+ global _urllib3_urlopen
918
+ from urllib3 import request
919
+ from urllib3.poolmanager import PoolManager
920
+
921
+ if isinstance(urlopen, PoolManager):
922
+ urlopen = partial(urlopen.request, "GET", preload_content=False)
923
+ elif urlopen is None:
924
+ if _urllib3_urlopen is None:
925
+ urlopen = _urllib3_urlopen = partial(cast(Callable, request), "GET", preload_content=False)
926
+ else:
927
+ urlopen = _urllib3_urlopen
928
+ else:
929
+ urlopen = partial(urlopen, preload_content=False)
930
+
931
+ def urlopen_wrapper(url: str, headers: None | Mapping = headers):
932
+ resp = urlopen(url, headers=headers)
933
+ if resp.status >= 400:
934
+ raise HTTPError(resp.url, resp.status, resp.reason, resp.headers, resp)
935
+ return resp
936
+
937
+ super().__init__(
938
+ url,
939
+ headers=headers,
940
+ start=start,
941
+ seek_threshold=seek_threshold,
942
+ urlopen=urlopen_wrapper,
943
+ )
944
+
945
+ __all__.append("Urllib3FileReader")
946
+
947
+
948
+ if find_spec("requests"):
949
+ _requests_urlopen = None
390
950
 
391
951
  class RequestsFileReader(HTTPFileReader):
392
952
 
@@ -394,13 +954,26 @@ try:
394
954
  self,
395
955
  /,
396
956
  url: str | Callable[[], str],
397
- headers: Optional[Mapping] = None,
957
+ headers: None | Mapping = None,
398
958
  start: int = 0,
399
959
  seek_threshold: int = 1 << 20,
400
- urlopen: Callable = Session().get,
960
+ urlopen = None,
401
961
  ):
402
- def urlopen_wrapper(url: str, headers: Optional[Mapping] = headers):
403
- resp = urlopen(url, headers=headers, stream=True)
962
+ global _requests_urlopen
963
+ from requests import request, Session
964
+ if isinstance(urlopen, Session):
965
+ urlopen = partial(urlopen.request, "GET", stream=True)
966
+ elif urlopen is None:
967
+ if _requests_urlopen is None:
968
+ if "__del__" not in Session.__dict__:
969
+ setattr(Session, "__del__", lambda self, /: self.close())
970
+ urlopen = _requests_urlopen = partial(request, "GET", stream=True)
971
+ else:
972
+ urlopen = _requests_urlopen
973
+ else:
974
+ urlopen = partial(urlopen, stream=True)
975
+ def urlopen_wrapper(url: str, headers: None | Mapping = headers):
976
+ resp = urlopen(url, headers=headers)
404
977
  resp.raise_for_status()
405
978
  return resp
406
979
  super().__init__(
@@ -411,41 +984,217 @@ try:
411
984
  urlopen=urlopen_wrapper,
412
985
  )
413
986
 
414
- def _add_start(self, delta: int, /):
415
- pass
416
-
417
987
  @funcproperty
418
988
  def file(self, /) -> BinaryIO:
419
989
  return self.response.raw
420
990
 
421
- def tell(self, /) -> int:
422
- start = self.start
423
- if start >= self.length:
424
- return start
425
- return start + self.file.tell()
426
-
427
991
  __all__.append("RequestsFileReader")
428
- except ImportError:
429
- pass
430
992
 
431
993
 
432
- try:
433
- from aiohttp import ClientSession
994
+ if find_spec("aiohttp"):
995
+ from types import MethodType
434
996
 
435
- ...
436
- except ImportError:
437
- pass
997
+ _aiohttp_urlopen = None
438
998
 
999
+ class AiohttpFileReader(AsyncHTTPFileReader):
439
1000
 
440
- try:
441
- from httpx import Client, AsyncClient
1001
+ async def __ainit__(
1002
+ self,
1003
+ /,
1004
+ url: str | Callable[[], str] | Callable[[], Awaitable[str]],
1005
+ headers: None | Mapping = None,
1006
+ start: int = 0,
1007
+ seek_threshold: int = 1 << 20,
1008
+ urlopen = None,
1009
+ ):
1010
+ global _aiohttp_urlopen
1011
+ from aiohttp import request, ClientSession
442
1012
 
443
- ...
444
- except ImportError:
445
- pass
1013
+ close_session = True
1014
+ if isinstance(urlopen, ClientSession):
1015
+ urlopen = urlopen.get
1016
+ close_session = False
1017
+ if urlopen is None:
1018
+ if _aiohttp_urlopen is None:
1019
+ urlopen = _aiohttp_urlopen = partial(request, "GET")
1020
+ else:
1021
+ urlopen = _aiohttp_urlopen
1022
+ else:
1023
+ func = urlopen
1024
+ if isinstance(func, partial):
1025
+ func = func.func
1026
+ close_session = not (
1027
+ isinstance(func, MethodType) and
1028
+ isinstance(func.__self__, ClientSession)
1029
+ )
1030
+ async def urlopen_wrapper(url: str, headers: None | Mapping = headers):
1031
+ resp = await urlopen(url, headers=headers).__aenter__()
1032
+ async def aclose():
1033
+ if close_session:
1034
+ try:
1035
+ await resp._session.close()
1036
+ except AttributeError:
1037
+ pass
1038
+ resp.close()
1039
+ resp.aclose = aclose
1040
+ resp.raise_for_status()
1041
+ return resp
1042
+ await super().__ainit__(
1043
+ url=url,
1044
+ headers=headers,
1045
+ start=start,
1046
+ seek_threshold=seek_threshold,
1047
+ urlopen=urlopen_wrapper,
1048
+ )
446
1049
 
1050
+ @funcproperty
1051
+ def file(self, /) -> BinaryIO:
1052
+ return self.response.content
447
1053
 
448
- # TODO: 实现 AsyncHTTPFileReader
449
- # TODO: 支持异步文件,使用 aiohttp,参考 aiofiles 的接口实现
450
- # TODO: 设计实现一个 HTTPFileWriter,用于实现上传,关闭后视为上传完成
1054
+ @funcproperty
1055
+ def file_closed(self, /) -> bool:
1056
+ return self.response.closed
1057
+
1058
+ __all__.append("AiohttpFileReader")
1059
+
1060
+
1061
+ if find_spec("httpx"):
1062
+ from filewrap import bytes_iter_to_reader, bytes_iter_to_async_reader
1063
+
1064
+ _httpx_urlopen = None
1065
+ _httpx_urlopen_async = None
1066
+
1067
+ class HttpxFileReader(HTTPFileReader):
451
1068
 
1069
+ def __init__(
1070
+ self,
1071
+ /,
1072
+ url: str | Callable[[], str],
1073
+ headers: None | Mapping = None,
1074
+ start: int = 0,
1075
+ seek_threshold: int = 1 << 20,
1076
+ urlopen = None,
1077
+ ):
1078
+ global _httpx_urlopen
1079
+ from httpx import stream, Client
1080
+
1081
+ if isinstance(urlopen, Client):
1082
+ urlopen = partial(urlopen.stream, "GET")
1083
+ elif urlopen is None:
1084
+ if _httpx_urlopen is None:
1085
+ if "__del__" not in Client.__dict__:
1086
+ setattr(Client, "__del__", lambda self: self.close())
1087
+ urlopen = _httpx_urlopen = partial(stream, "GET")
1088
+ else:
1089
+ urlopen = _httpx_urlopen
1090
+
1091
+ def urlopen_wrapper(url: str, headers: None | Mapping = headers):
1092
+ context = urlopen(url, headers=headers)
1093
+ resp = context.__enter__()
1094
+ resp.raise_for_status()
1095
+ resp.context = context
1096
+ file = bytes_iter_to_reader(resp.iter_raw())
1097
+ self.__dict__["file"] = file
1098
+ return resp
1099
+
1100
+ super().__init__(
1101
+ url,
1102
+ headers=headers,
1103
+ start=start,
1104
+ seek_threshold=seek_threshold,
1105
+ urlopen=urlopen_wrapper,
1106
+ )
1107
+
1108
+ @funcproperty
1109
+ def file_closed(self, /) -> bool:
1110
+ return self.response.is_closed
1111
+
1112
+ class AsyncHttpxFileReader(AsyncHTTPFileReader):
1113
+
1114
+ async def __ainit__(
1115
+ self,
1116
+ /,
1117
+ url: str | Callable[[], str] | Callable[[], Awaitable[str]],
1118
+ headers: None | Mapping = None,
1119
+ start: int = 0,
1120
+ seek_threshold: int = 1 << 20,
1121
+ urlopen = None,
1122
+ ):
1123
+ global _httpx_urlopen_async
1124
+ from httpx import AsyncClient
1125
+
1126
+ if isinstance(urlopen, AsyncClient):
1127
+ urlopen = partial(urlopen.stream, "GET")
1128
+ elif urlopen is None:
1129
+ if _httpx_urlopen_async is None:
1130
+ if "__del__" not in AsyncClient.__dict__:
1131
+ setattr(AsyncClient, "__del__", lambda self: run_async(self.aclose()))
1132
+ def async_stream(
1133
+ method,
1134
+ url,
1135
+ params = None,
1136
+ content = None,
1137
+ data = None,
1138
+ files = None,
1139
+ json = None,
1140
+ headers = None,
1141
+ cookies = None,
1142
+ auth = None,
1143
+ proxy = None,
1144
+ proxies = None,
1145
+ timeout = 5.0,
1146
+ follow_redirects = False,
1147
+ verify = True,
1148
+ cert = None,
1149
+ trust_env = True,
1150
+ ):
1151
+ client = AsyncClient(
1152
+ cookies=cookies,
1153
+ proxy=proxy,
1154
+ proxies=proxies,
1155
+ cert=cert,
1156
+ verify=verify,
1157
+ timeout=timeout,
1158
+ trust_env=trust_env,
1159
+ )
1160
+ return client.stream(
1161
+ method=method,
1162
+ url=url,
1163
+ content=content,
1164
+ data=data,
1165
+ files=files,
1166
+ json=json,
1167
+ params=params,
1168
+ headers=headers,
1169
+ auth=auth,
1170
+ follow_redirects=follow_redirects,
1171
+ )
1172
+ urlopen = _httpx_urlopen_async = partial(async_stream, "GET")
1173
+ else:
1174
+ urlopen = _httpx_urlopen_async
1175
+
1176
+ async def urlopen_wrapper(url: str, headers: None | Mapping = headers):
1177
+ context = urlopen(url, headers=headers)
1178
+ resp = await context.__aenter__()
1179
+ resp.raise_for_status()
1180
+ resp.context = context
1181
+ file = bytes_iter_to_async_reader(resp.aiter_raw())
1182
+ self.__dict__["file"] = file
1183
+ return resp
1184
+
1185
+ await super().__ainit__(
1186
+ url=url,
1187
+ headers=headers,
1188
+ start=start,
1189
+ seek_threshold=seek_threshold,
1190
+ urlopen=urlopen_wrapper,
1191
+ )
1192
+
1193
+ @funcproperty
1194
+ def file_closed(self, /) -> bool:
1195
+ return self.response.is_closed
1196
+
1197
+ __all__.append("HttpxFileReader")
1198
+ __all__.append("AsyncHttpxFileReader")
1199
+
1200
+ # TODO: 设计实现一个 HTTPFileWriter,用于实现上传,关闭后视为上传完成
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-httpfile
3
- Version: 0.0.2.1
3
+ Version: 0.0.4
4
4
  Summary: Python httpfile.
5
5
  Home-page: https://github.com/ChenyangGao/web-mount-packs/tree/main/python-module/python-httpfile
6
6
  License: MIT
@@ -22,8 +22,10 @@ Classifier: Topic :: Software Development
22
22
  Classifier: Topic :: Software Development :: Libraries
23
23
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
24
  Requires-Dist: http_response
25
- Requires-Dist: python-property
26
- Requires-Dist: python-urlopen
25
+ Requires-Dist: python-asynctools (>=0.0.5)
26
+ Requires-Dist: python-filewrap (>=0.2.1)
27
+ Requires-Dist: python-property (>=0.0.2)
28
+ Requires-Dist: python-urlopen (>=0.0.7.1)
27
29
  Project-URL: Repository, https://github.com/ChenyangGao/web-mount-packs/tree/main/python-module/python-httpfile
28
30
  Description-Content-Type: text/markdown
29
31
 
@@ -0,0 +1,7 @@
1
+ LICENSE,sha256=o5242_N2TgDsWwFhPn7yr8YJNF7XsJM5NxUMtcT97bc,1100
2
+ httpfile/__init__.py,sha256=PAqvhrEAfTEzVSZ7wbLrPplYX-8G1epkW7MZt2sWWaw,38657
3
+ httpfile/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ python_httpfile-0.0.4.dist-info/LICENSE,sha256=o5242_N2TgDsWwFhPn7yr8YJNF7XsJM5NxUMtcT97bc,1100
5
+ python_httpfile-0.0.4.dist-info/METADATA,sha256=1CtTQ_EoeIP9eVYlevzlEBdA9D26S9pHfZmdTOnXB0Q,1520
6
+ python_httpfile-0.0.4.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
7
+ python_httpfile-0.0.4.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- LICENSE,sha256=o5242_N2TgDsWwFhPn7yr8YJNF7XsJM5NxUMtcT97bc,1100
2
- httpfile/__init__.py,sha256=9qTmQOShcWtnLtIsqL3NbIeH9M2tAWE9NquEjyFs9ZM,13416
3
- httpfile/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- python_httpfile-0.0.2.1.dist-info/LICENSE,sha256=o5242_N2TgDsWwFhPn7yr8YJNF7XsJM5NxUMtcT97bc,1100
5
- python_httpfile-0.0.2.1.dist-info/METADATA,sha256=tKxwg73IETWHCmf-F7--YKrQtlgn15r4iUxA7BzDkqQ,1416
6
- python_httpfile-0.0.2.1.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
7
- python_httpfile-0.0.2.1.dist-info/RECORD,,