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