Let us walk on the 3-isogeny graph
Loading...
Searching...
No Matches
lazy_wheel.py
Go to the documentation of this file.
1"""Lazy ZIP over HTTP"""
2
3__all__ = ["HTTPRangeRequestUnsupported", "dist_from_wheel_url"]
4
5from bisect import bisect_left, bisect_right
6from contextlib import contextmanager
7from tempfile import NamedTemporaryFile
8from typing import Any, Dict, Generator, List, Optional, Tuple
9from zipfile import BadZipFile, ZipFile
10
11from pip._vendor.packaging.utils import canonicalize_name
12from pip._vendor.requests.models import CONTENT_CHUNK_SIZE, Response
13
14from pip._internal.metadata import BaseDistribution, MemoryWheel, get_wheel_distribution
15from pip._internal.network.session import PipSession
16from pip._internal.network.utils import HEADERS, raise_for_status, response_chunks
17
18
20 pass
21
22
23def dist_from_wheel_url(name: str, url: str, session: PipSession) -> BaseDistribution:
24 """Return a distribution object from the given wheel URL.
25
26 This uses HTTP range requests to only fetch the portion of the wheel
27 containing metadata, just enough for the object to be constructed.
28 If such requests are not supported, HTTPRangeRequestUnsupported
29 is raised.
30 """
31 with LazyZipOverHTTP(url, session) as zf:
32 # For read-only ZIP files, ZipFile only needs methods read,
33 # seek, seekable and tell, not the whole IO protocol.
34 wheel = MemoryWheel(zf.name, zf) # type: ignore
35 # After context manager exit, wheel.name
36 # is an invalid file by intention.
37 return get_wheel_distribution(wheel, canonicalize_name(name))
38
39
41 """File-like object mapped to a ZIP file over HTTP.
42
43 This uses HTTP range requests to lazily fetch the file's content,
44 which is supposed to be fed to ZipFile. If such requests are not
45 supported by the server, raise HTTPRangeRequestUnsupported
46 during initialization.
47 """
48
50 self, url: str, session: PipSession, chunk_size: int = CONTENT_CHUNK_SIZE
51 ) -> None:
52 head = session.head(url, headers=HEADERS)
53 raise_for_status(head)
54 assert head.status_code == 200
55 self._session, self._url, self._chunk_size = session, url, chunk_size
56 self._length = int(head.headers["Content-Length"])
57 self._file = NamedTemporaryFile()
58 self.truncate(self._length)
59 self._left: List[int] = []
60 self._right: List[int] = []
61 if "bytes" not in head.headers.get("Accept-Ranges", "none"):
62 raise HTTPRangeRequestUnsupported("range request is not supported")
63 self._check_zip()
64
65 @property
66 def mode(self) -> str:
67 """Opening mode, which is always rb."""
68 return "rb"
69
70 @property
71 def name(self) -> str:
72 """Path to the underlying file."""
73 return self._file.name
74
75 def seekable(self) -> bool:
76 """Return whether random access is supported, which is True."""
77 return True
78
79 def close(self) -> None:
80 """Close the file."""
81 self._file.close()
82
83 @property
84 def closed(self) -> bool:
85 """Whether the file is closed."""
86 return self._file.closed
87
88 def read(self, size: int = -1) -> bytes:
89 """Read up to size bytes from the object and return them.
90
91 As a convenience, if size is unspecified or -1,
92 all bytes until EOF are returned. Fewer than
93 size bytes may be returned if EOF is reached.
94 """
95 download_size = max(size, self._chunk_size)
96 start, length = self.tell(), self._length
97 stop = length if size < 0 else min(start + download_size, length)
98 start = max(0, stop - download_size)
99 self._download(start, stop - 1)
100 return self._file.read(size)
101
102 def readable(self) -> bool:
103 """Return whether the file is readable, which is True."""
104 return True
105
106 def seek(self, offset: int, whence: int = 0) -> int:
107 """Change stream position and return the new absolute position.
108
109 Seek to offset relative position indicated by whence:
110 * 0: Start of stream (the default). pos should be >= 0;
111 * 1: Current position - pos may be negative;
112 * 2: End of stream - pos usually negative.
113 """
114 return self._file.seek(offset, whence)
115
116 def tell(self) -> int:
117 """Return the current position."""
118 return self._file.tell()
119
120 def truncate(self, size: Optional[int] = None) -> int:
121 """Resize the stream to the given size in bytes.
122
123 If size is unspecified resize to the current position.
124 The current stream position isn't changed.
125
126 Return the new file size.
127 """
128 return self._file.truncate(size)
129
130 def writable(self) -> bool:
131 """Return False."""
132 return False
133
134 def __enter__(self) -> "LazyZipOverHTTP":
135 self._file.__enter__()
136 return self
137
138 def __exit__(self, *exc: Any) -> None:
139 self._file.__exit__(*exc)
140
141 @contextmanager
142 def _stay(self) -> Generator[None, None, None]:
143 """Return a context manager keeping the position.
144
145 At the end of the block, seek back to original position.
146 """
147 pos = self.tell()
148 try:
149 yield
150 finally:
151 self.seek(pos)
152
153 def _check_zip(self) -> None:
154 """Check and download until the file is a valid ZIP."""
155 end = self._length - 1
156 for start in reversed(range(0, end, self._chunk_size)):
157 self._download(start, end)
158 with self._stay():
159 try:
160 # For read-only ZIP files, ZipFile only needs
161 # methods read, seek, seekable and tell.
162 ZipFile(self) # type: ignore
163 except BadZipFile:
164 pass
165 else:
166 break
167
169 self, start: int, end: int, base_headers: Dict[str, str] = HEADERS
170 ) -> Response:
171 """Return HTTP response to a range request from start to end."""
172 headers = base_headers.copy()
173 headers["Range"] = f"bytes={start}-{end}"
174 # TODO: Get range requests to be correctly cached
175 headers["Cache-Control"] = "no-cache"
176 return self._session.get(self._url, headers=headers, stream=True)
177
179 self, start: int, end: int, left: int, right: int
180 ) -> Generator[Tuple[int, int], None, None]:
181 """Return a generator of intervals to be fetched.
182
183 Args:
184 start (int): Start of needed interval
185 end (int): End of needed interval
186 left (int): Index of first overlapping downloaded data
187 right (int): Index after last overlapping downloaded data
188 """
189 lslice, rslice = self._left[left:right], self._right[left:right]
190 i = start = min([start] + lslice[:1])
191 end = max([end] + rslice[-1:])
192 for j, k in zip(lslice, rslice):
193 if j > i:
194 yield i, j - 1
195 i = k + 1
196 if i <= end:
197 yield i, end
198 self._left[left:right], self._right[left:right] = [start], [end]
199
200 def _download(self, start: int, end: int) -> None:
201 """Download bytes from start to end inclusively."""
202 with self._stay():
203 left = bisect_left(self._right, start)
204 right = bisect_right(self._left, end)
205 for start, end in self._merge(start, end, left, right):
206 response = self._stream_response(start, end)
208 self.seek(start)
209 for chunk in response_chunks(response, self._chunk_size):
210 self._file.write(chunk)
int truncate(self, Optional[int] size=None)
Generator[None, None, None] _stay(self)
int seek(self, int offset, int whence=0)
None _download(self, int start, int end)
Generator[Tuple[int, int], None, None] _merge(self, int start, int end, int left, int right)
Response _stream_response(self, int start, int end, Dict[str, str] base_headers=HEADERS)
None __init__(self, str url, PipSession session, int chunk_size=CONTENT_CHUNK_SIZE)
Definition lazy_wheel.py:51
for i