Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/fsspec/implementations/http.py: 23%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

557 statements  

1import asyncio 

2import io 

3import logging 

4import random 

5import re 

6import weakref 

7from copy import copy 

8from urllib.parse import urlparse 

9 

10import aiohttp 

11import yarl 

12 

13from fsspec.asyn import AbstractAsyncStreamedFile, AsyncFileSystem, sync, sync_wrapper 

14from fsspec.callbacks import DEFAULT_CALLBACK 

15from fsspec.exceptions import FSTimeoutError 

16from fsspec.spec import AbstractBufferedFile 

17from fsspec.utils import ( 

18 DEFAULT_BLOCK_SIZE, 

19 glob_translate, 

20 isfilelike, 

21 nullcontext, 

22 range_response_window, 

23 response_is_requested_range, 

24 tokenize, 

25) 

26 

27from ..caching import AllBytes 

28 

29# https://stackoverflow.com/a/15926317/3821154 

30# The attribute scan must stay linear: it stops at the next "<" or ">" and the 

31# whitespace before href is a lookbehind rather than a second quantifier, so a 

32# page cannot make it backtrack cubically (1 KB of whitespace after "<a" 

33# used to stall ls() for seconds, 10 KB for hours). 

34ex = re.compile(r"""<(a|A)\s[^<>]*?(?<=\s)(href|HREF)=["'](?P<url>[^"']+)""") 

35ex2 = re.compile(r"""(?P<url>http[s]?://[-a-zA-Z0-9@:%_+.~#?&/=]+)""") 

36logger = logging.getLogger("fsspec.http") 

37 

38_RETRYABLE_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504}) 

39# dropped or reset connections, truncated bodies, timeouts 

40_RETRYABLE_EXCEPTIONS = ( 

41 aiohttp.ClientConnectionError, 

42 aiohttp.ClientPayloadError, 

43 asyncio.TimeoutError, 

44) 

45_RETRY_MAX_WAIT = 60.0 

46 

47 

48def _is_retryable(exc, statuses=_RETRYABLE_STATUSES, exceptions=None): 

49 """Whether a failed HTTP read is worth repeating. 

50 

51 A response error is retried when its status is in ``statuses``; any 

52 other error is retried when it is an instance of ``exceptions``. The 

53 default (None) is ``_RETRYABLE_EXCEPTIONS`` minus SSL failures, which 

54 are deterministic. Anything else (including the ``FileNotFoundError``/ 

55 ``PermissionError`` a subclass may map 4xx codes to) propagates at once. 

56 """ 

57 if isinstance(exc, aiohttp.ClientResponseError): 

58 return exc.status in statuses 

59 if exceptions is None: 

60 return isinstance(exc, _RETRYABLE_EXCEPTIONS) and not isinstance( 

61 exc, aiohttp.ClientSSLError 

62 ) 

63 return isinstance(exc, exceptions) 

64 

65 

66def _retry_after(exc): 

67 """Delta-seconds ``Retry-After`` carried by a response error, else None.""" 

68 headers = getattr(exc, "headers", None) 

69 if not headers: 

70 return None 

71 try: 

72 return max(0.0, float(headers.get("Retry-After"))) 

73 except (TypeError, ValueError): 

74 return None 

75 

76 

77async def _with_retries(attempt, fs, label): 

78 """Await ``attempt()``, repeating it on transient failures. 

79 

80 ``attempt`` is a zero-argument coroutine function covering the whole 

81 request *and* body read, so a connection dropped mid-body counts as a 

82 failure too. The policy comes from the ``retries``, ``retry_wait``, 

83 ``retry_statuses`` and ``retry_exceptions`` attributes of ``fs``. A 

84 ``Retry-After`` header is honoured when present; otherwise the wait 

85 doubles from ``retry_wait`` on each retry, jittered. Either way the wait 

86 is capped at ``_RETRY_MAX_WAIT`` seconds. Non-retryable errors and the 

87 final failure propagate unchanged. 

88 """ 

89 retries = fs.retries 

90 for n in range(retries + 1): 

91 try: 

92 return await attempt() 

93 except Exception as exc: 

94 if n >= retries or not _is_retryable( 

95 exc, fs.retry_statuses, fs.retry_exceptions 

96 ): 

97 raise 

98 delay = _retry_after(exc) 

99 if delay is None: 

100 delay = fs.retry_wait * 2**n * (0.5 + random.random()) 

101 delay = min(_RETRY_MAX_WAIT, delay) 

102 logger.warning( 

103 "%s: attempt %d/%d failed (%r); retrying in %.1fs", 

104 label, 

105 n + 1, 

106 retries + 1, 

107 exc, 

108 delay, 

109 ) 

110 await asyncio.sleep(delay) 

111 

112 

113async def get_client(**kwargs): 

114 return aiohttp.ClientSession(**kwargs) 

115 

116 

117class HTTPFileSystem(AsyncFileSystem): 

118 """ 

119 Simple File-System for fetching data via HTTP(S) 

120 

121 ``ls()`` is implemented by loading the parent page and doing a regex 

122 match on the result. If simple_link=True, anything of the form 

123 "http(s)://server.com/stuff?thing=other"; otherwise only links within 

124 HTML href tags will be used. 

125 

126 URLs are passed unfiltered to aiohttp, so all addresses are accessible. Where URLs are 

127 supplied by a user, the calling application may wish to filter to prevent scanning. 

128 """ 

129 

130 protocol = ("http", "https") 

131 sep = "/" 

132 

133 def __init__( 

134 self, 

135 simple_links=True, 

136 block_size=None, 

137 same_scheme=True, 

138 size_policy=None, 

139 cache_type="bytes", 

140 cache_options=None, 

141 asynchronous=False, 

142 loop=None, 

143 client_kwargs=None, 

144 get_client=get_client, 

145 encoded=False, 

146 retries=3, 

147 retry_wait=1.0, 

148 retry_statuses=None, 

149 retry_exceptions=None, 

150 **storage_options, 

151 ): 

152 """ 

153 NB: if this is called async, you must await set_client 

154 

155 Parameters 

156 ---------- 

157 block_size: int 

158 Blocks to read bytes; if 0, will default to raw requests file-like 

159 objects instead of HTTPFile instances 

160 simple_links: bool 

161 If True, will consider both HTML <a> tags and anything that looks 

162 like a URL; if False, will consider only the former. 

163 same_scheme: True 

164 When doing ls/glob, if this is True, only consider paths that have 

165 http/https matching the input URLs. 

166 size_policy: this argument is deprecated 

167 client_kwargs: dict 

168 Passed to aiohttp.ClientSession, see 

169 https://docs.aiohttp.org/en/stable/client_reference.html 

170 For example, ``{'auth': aiohttp.BasicAuth('user', 'pass')}`` 

171 get_client: Callable[..., aiohttp.ClientSession] 

172 A callable, which takes keyword arguments and constructs 

173 an aiohttp.ClientSession. Its state will be managed by 

174 the HTTPFileSystem class. 

175 retries: int 

176 How many times a transient failure of a read (see below) 

177 is retried in ``cat_file``, ``HTTPFile`` block reads and the 

178 ``info`` lookup behind ``open``; 0 disables retries. 

179 retry_wait: float 

180 Seconds to wait before the first retry; doubled on each further 

181 retry, with jitter. A ``Retry-After`` header sent by the server 

182 takes precedence. Either wait is capped at 60 s. 

183 retry_statuses: container of int or None 

184 HTTP status codes of a failed read that are retried; default 

185 (None) is 408, 425, 429, 500, 502, 503 and 504. 

186 retry_exceptions: exception class, tuple of classes or None 

187 Errors other than a response status that are retried; default 

188 (None) is dropped or reset connections (except SSL failures), 

189 truncated bodies and timeouts. 

190 storage_options: key-value 

191 Any other parameters passed on to requests 

192 cache_type, cache_options: defaults used in open() 

193 """ 

194 super().__init__(self, asynchronous=asynchronous, loop=loop, **storage_options) 

195 self.block_size = block_size if block_size is not None else DEFAULT_BLOCK_SIZE 

196 self.simple_links = simple_links 

197 self.same_schema = same_scheme 

198 self.cache_type = cache_type 

199 self.cache_options = cache_options 

200 self.client_kwargs = client_kwargs or {} 

201 self.get_client = get_client 

202 self.encoded = encoded 

203 if retries < 0: 

204 raise ValueError(f"retries must be >= 0, got {retries!r}") 

205 if retry_wait < 0: 

206 raise ValueError(f"retry_wait must be >= 0, got {retry_wait!r}") 

207 self.retries = retries 

208 self.retry_wait = retry_wait 

209 self.retry_statuses = ( 

210 retry_statuses if retry_statuses is not None else _RETRYABLE_STATUSES 

211 ) 

212 self.retry_exceptions = retry_exceptions 

213 self.kwargs = storage_options 

214 self._session = None 

215 

216 # Clean caching-related parameters from `storage_options` 

217 # before propagating them as `request_options` through `self.kwargs`. 

218 # TODO: Maybe rename `self.kwargs` to `self.request_options` to make 

219 # it clearer. 

220 request_options = copy(storage_options) 

221 self.use_listings_cache = request_options.pop("use_listings_cache", False) 

222 request_options.pop("listings_expiry_time", None) 

223 request_options.pop("max_paths", None) 

224 request_options.pop("skip_instance_cache", None) 

225 self.kwargs = request_options 

226 

227 @property 

228 def fsid(self): 

229 return "http" 

230 

231 def encode_url(self, url): 

232 return yarl.URL(url, encoded=self.encoded) 

233 

234 @staticmethod 

235 def close_session(loop, session): 

236 if loop is not None and loop.is_running(): 

237 try: 

238 sync(loop, session.close, timeout=0.1) 

239 return 

240 except (TimeoutError, FSTimeoutError, NotImplementedError, RuntimeError): 

241 pass 

242 connector = getattr(session, "_connector", None) 

243 if connector is not None: 

244 # close after loop is dead 

245 connector._close() 

246 

247 async def set_session(self): 

248 if self._session is None: 

249 self._session = await self.get_client(loop=self.loop, **self.client_kwargs) 

250 if not self.asynchronous: 

251 weakref.finalize(self, self.close_session, self.loop, self._session) 

252 return self._session 

253 

254 @classmethod 

255 def _strip_protocol(cls, path): 

256 """For HTTP, we always want to keep the full URL""" 

257 return path 

258 

259 @classmethod 

260 def _parent(cls, path): 

261 # override, since _strip_protocol is different for URLs 

262 par = super()._parent(path) 

263 if len(par) > 7: # "http://..." 

264 return par 

265 return "" 

266 

267 async def _ls_real(self, url, detail=True, **kwargs): 

268 # ignoring URL-encoded arguments 

269 kw = self.kwargs.copy() 

270 kw.update(kwargs) 

271 logger.debug(url) 

272 session = await self.set_session() 

273 async with session.get(self.encode_url(url), **kw) as r: 

274 self._raise_not_found_for_status(r, url) 

275 

276 if "Content-Type" in r.headers: 

277 mimetype = r.headers["Content-Type"].partition(";")[0] 

278 else: 

279 mimetype = None 

280 

281 if mimetype in ("text/html", None): 

282 try: 

283 text = await r.text(errors="ignore") 

284 if self.simple_links: 

285 links = ex2.findall(text) + [u[2] for u in ex.findall(text)] 

286 else: 

287 links = [u[2] for u in ex.findall(text)] 

288 except UnicodeDecodeError: 

289 links = [] # binary, not HTML 

290 else: 

291 links = [] 

292 

293 out = set() 

294 parts = urlparse(url) 

295 for l in links: 

296 if isinstance(l, tuple): 

297 l = l[1] 

298 if l.startswith("/") and len(l) > 1: 

299 # absolute URL on this server 

300 l = f"{parts.scheme}://{parts.netloc}{l}" 

301 if l.startswith("http"): 

302 if self.same_schema and l.startswith(url.rstrip("/") + "/"): 

303 out.add(l) 

304 elif l.replace("https", "http").startswith( 

305 url.replace("https", "http").rstrip("/") + "/" 

306 ): 

307 # allowed to cross http <-> https 

308 out.add(l) 

309 else: 

310 if l not in ["..", "../"]: 

311 # Ignore FTP-like "parent" 

312 out.add("/".join([url.rstrip("/"), l.lstrip("/")])) 

313 if not out and url.endswith("/"): 

314 out = await self._ls_real(url.rstrip("/"), detail=False, **kwargs) 

315 if detail: 

316 return [ 

317 { 

318 "name": u, 

319 "size": None, 

320 "type": "directory" if u.endswith("/") else "file", 

321 } 

322 for u in out 

323 ] 

324 else: 

325 return sorted(out) 

326 

327 async def _ls(self, url, detail=True, **kwargs): 

328 if self.use_listings_cache and url in self.dircache: 

329 out = self.dircache[url] 

330 else: 

331 out = await self._ls_real(url, detail=True, **kwargs) 

332 self.dircache[url] = out 

333 if detail: 

334 return out 

335 return sorted(entry["name"] for entry in out) 

336 

337 ls = sync_wrapper(_ls) 

338 

339 def _raise_not_found_for_status(self, response, url): 

340 """ 

341 Raises FileNotFoundError for 404s, otherwise uses raise_for_status. 

342 """ 

343 if response.status == 404: 

344 raise FileNotFoundError(url) 

345 response.raise_for_status() 

346 

347 async def _cat_file(self, url, start=None, end=None, **kwargs): 

348 kw = self.kwargs.copy() 

349 kw.update(kwargs) 

350 logger.debug(url) 

351 

352 ranged = start is not None or end is not None 

353 if ranged: 

354 if start == end or (start is None and end == 0): 

355 return b"" 

356 headers = kw.pop("headers", {}).copy() 

357 

358 headers["Range"] = await self._process_limits(url, start, end) 

359 kw["headers"] = headers 

360 session = await self.set_session() 

361 

362 async def _once(): 

363 async with session.get(self.encode_url(url), **kw) as r: 

364 out = await r.read() 

365 self._raise_not_found_for_status(r, url) 

366 # a server that ignores the Range header replies with the whole 

367 # file, which is not the window the caller asked for 

368 return ( 

369 range_response_window(out, r.status, r.headers, start, end) 

370 if ranged 

371 else out 

372 ) 

373 

374 return await _with_retries(_once, self, url) 

375 

376 async def _get_file( 

377 self, rpath, lpath, chunk_size=5 * 2**20, callback=DEFAULT_CALLBACK, **kwargs 

378 ): 

379 kw = self.kwargs.copy() 

380 kw.update(kwargs) 

381 logger.debug(rpath) 

382 session = await self.set_session() 

383 async with session.get(self.encode_url(rpath), **kw) as r: 

384 try: 

385 size = int(r.headers["content-length"]) 

386 except (ValueError, KeyError): 

387 size = None 

388 

389 callback.set_size(size) 

390 self._raise_not_found_for_status(r, rpath) 

391 if isfilelike(lpath): 

392 outfile = lpath 

393 else: 

394 outfile = open(lpath, "wb") # noqa: ASYNC230 

395 

396 try: 

397 chunk = True 

398 while chunk: 

399 chunk = await r.content.read(chunk_size) 

400 outfile.write(chunk) 

401 callback.relative_update(len(chunk)) 

402 finally: 

403 if not isfilelike(lpath): 

404 outfile.close() 

405 

406 async def _put_file( 

407 self, 

408 lpath, 

409 rpath, 

410 chunk_size=5 * 2**20, 

411 callback=DEFAULT_CALLBACK, 

412 method="post", 

413 mode="overwrite", 

414 **kwargs, 

415 ): 

416 if mode != "overwrite": 

417 raise NotImplementedError("Exclusive write") 

418 

419 async def gen_chunks(): 

420 # Support passing arbitrary file-like objects 

421 # and use them instead of streams. 

422 if isinstance(lpath, io.IOBase): 

423 context = nullcontext(lpath) 

424 use_seek = False # might not support seeking 

425 else: 

426 context = open(lpath, "rb") # noqa: ASYNC230 

427 use_seek = True 

428 

429 with context as f: 

430 if use_seek: 

431 callback.set_size(f.seek(0, 2)) 

432 f.seek(0) 

433 else: 

434 callback.set_size(getattr(f, "size", None)) 

435 

436 chunk = f.read(chunk_size) 

437 while chunk: 

438 yield chunk 

439 callback.relative_update(len(chunk)) 

440 chunk = f.read(chunk_size) 

441 

442 kw = self.kwargs.copy() 

443 kw.update(kwargs) 

444 session = await self.set_session() 

445 

446 method = method.lower() 

447 if method not in ("post", "put"): 

448 raise ValueError( 

449 f"method has to be either 'post' or 'put', not: {method!r}" 

450 ) 

451 

452 meth = getattr(session, method) 

453 async with meth(self.encode_url(rpath), data=gen_chunks(), **kw) as resp: 

454 self._raise_not_found_for_status(resp, rpath) 

455 

456 async def _exists(self, path, strict=False, **kwargs): 

457 kw = self.kwargs.copy() 

458 kw.update(kwargs) 

459 try: 

460 logger.debug(path) 

461 session = await self.set_session() 

462 r = await session.get(self.encode_url(path), **kw) 

463 async with r: 

464 if strict: 

465 self._raise_not_found_for_status(r, path) 

466 return r.status < 400 

467 except FileNotFoundError: 

468 return False 

469 except aiohttp.ClientError: 

470 if strict: 

471 raise 

472 return False 

473 

474 async def _isfile(self, path, **kwargs): 

475 return await self._exists(path, **kwargs) 

476 

477 def _open( 

478 self, 

479 path, 

480 mode="rb", 

481 block_size=None, 

482 autocommit=None, # XXX: This differs from the base class. 

483 cache_type=None, 

484 cache_options=None, 

485 size=None, 

486 **kwargs, 

487 ): 

488 """Make a file-like object 

489 

490 Parameters 

491 ---------- 

492 path: str 

493 Full URL with protocol 

494 mode: string 

495 must be "rb" 

496 block_size: int or None 

497 Bytes to download in one request; use instance value if None. If 

498 zero, will return a streaming Requests file-like instance. 

499 kwargs: key-value 

500 Any other parameters, passed to requests calls 

501 """ 

502 if mode != "rb": 

503 raise NotImplementedError 

504 block_size = block_size if block_size is not None else self.block_size 

505 kw = self.kwargs.copy() 

506 kw["asynchronous"] = self.asynchronous 

507 kw.update(kwargs) 

508 info = {} 

509 size = size or info.update(self.info(path, **kwargs)) or info["size"] 

510 session = sync(self.loop, self.set_session) 

511 if block_size and size and info.get("partial", True): 

512 return HTTPFile( 

513 self, 

514 path, 

515 session=session, 

516 block_size=block_size, 

517 mode=mode, 

518 size=size, 

519 cache_type=cache_type or self.cache_type, 

520 cache_options=cache_options or self.cache_options, 

521 loop=self.loop, 

522 **kw, 

523 ) 

524 else: 

525 return HTTPStreamFile( 

526 self, 

527 path, 

528 mode=mode, 

529 loop=self.loop, 

530 session=session, 

531 **kw, 

532 ) 

533 

534 async def open_async(self, path, mode="rb", size=None, **kwargs): 

535 session = await self.set_session() 

536 if size is None: 

537 try: 

538 size = (await self._info(path, **kwargs))["size"] 

539 except FileNotFoundError: 

540 pass 

541 return AsyncStreamFile( 

542 self, 

543 path, 

544 loop=self.loop, 

545 session=session, 

546 size=size, 

547 **kwargs, 

548 ) 

549 

550 def ukey(self, url): 

551 """Unique identifier; assume HTTP files are static, unchanging""" 

552 return tokenize(url, self.kwargs, self.protocol) 

553 

554 async def _info(self, url, **kwargs): 

555 """Get info of URL 

556 

557 Tries to access location via HEAD, and then GET methods, but does 

558 not fetch the data. 

559 

560 It is possible that the server does not supply any size information, in 

561 which case size will be given as None (and certain operations on the 

562 corresponding file will not work). 

563 """ 

564 info = {} 

565 session = await self.set_session() 

566 

567 async def _once(): 

568 # HEAD, then GET if that gave no size or failed for any reason 

569 # (many servers mishandle HEAD); only a GET failure counts as a 

570 # failed attempt. 

571 for policy in ["head", "get"]: 

572 try: 

573 info.update( 

574 await _file_info( 

575 self.encode_url(url), 

576 size_policy=policy, 

577 session=session, 

578 **self.kwargs, 

579 **kwargs, 

580 ) 

581 ) 

582 if info.get("size") is not None: 

583 break 

584 except Exception as exc: 

585 if policy == "get": 

586 raise 

587 logger.debug("", exc_info=exc) 

588 

589 try: 

590 await _with_retries(_once, self, url) 

591 except Exception as exc: 

592 # If get failed, then raise a FileNotFoundError 

593 raise FileNotFoundError(url) from exc 

594 

595 return {"name": url, "size": None, **info, "type": "file"} 

596 

597 async def _glob(self, path, maxdepth=None, **kwargs): 

598 """ 

599 Find files by glob-matching. 

600 

601 This implementation is idntical to the one in AbstractFileSystem, 

602 but "?" is not considered as a character for globbing, because it is 

603 so common in URLs, often identifying the "query" part. 

604 """ 

605 if maxdepth is not None and maxdepth < 1: 

606 raise ValueError("maxdepth must be at least 1") 

607 import re 

608 

609 ends_with_slash = path.endswith("/") # _strip_protocol strips trailing slash 

610 path = self._strip_protocol(path) 

611 append_slash_to_dirname = ends_with_slash or path.endswith(("/**", "/*")) 

612 idx_star = path.find("*") if path.find("*") >= 0 else len(path) 

613 idx_brace = path.find("[") if path.find("[") >= 0 else len(path) 

614 

615 min_idx = min(idx_star, idx_brace) 

616 

617 detail = kwargs.pop("detail", False) 

618 

619 if not has_magic(path): 

620 if await self._exists(path, **kwargs): 

621 if not detail: 

622 return [path] 

623 else: 

624 return {path: await self._info(path, **kwargs)} 

625 else: 

626 if not detail: 

627 return [] # glob of non-existent returns empty 

628 else: 

629 return {} 

630 elif "/" in path[:min_idx]: 

631 min_idx = path[:min_idx].rindex("/") 

632 root = path[: min_idx + 1] 

633 depth = path[min_idx + 1 :].count("/") + 1 

634 else: 

635 root = "" 

636 depth = path[min_idx + 1 :].count("/") + 1 

637 

638 if "**" in path: 

639 if maxdepth is not None: 

640 idx_double_stars = path.find("**") 

641 depth_double_stars = path[idx_double_stars:].count("/") + 1 

642 depth = depth - depth_double_stars + maxdepth 

643 else: 

644 depth = None 

645 

646 allpaths = await self._find( 

647 root, maxdepth=depth, withdirs=True, detail=True, **kwargs 

648 ) 

649 

650 pattern = glob_translate(path + ("/" if ends_with_slash else "")) 

651 pattern = re.compile(pattern) 

652 

653 out = { 

654 ( 

655 p.rstrip("/") 

656 if not append_slash_to_dirname 

657 and info["type"] == "directory" 

658 and p.endswith("/") 

659 else p 

660 ): info 

661 for p, info in sorted(allpaths.items()) 

662 if pattern.match(p.rstrip("/")) 

663 } 

664 

665 if detail: 

666 return out 

667 else: 

668 return list(out) 

669 

670 async def _isdir(self, path): 

671 # override, since all URLs are (also) files 

672 try: 

673 return bool(await self._ls(path)) 

674 except (FileNotFoundError, ValueError): 

675 return False 

676 

677 async def _pipe_file(self, path, value, mode="overwrite", **kwargs): 

678 """ 

679 Write bytes to a remote file over HTTP. 

680 

681 Parameters 

682 ---------- 

683 path : str 

684 Target URL where the data should be written 

685 value : bytes 

686 Data to be written 

687 mode : str 

688 Only 'overwrite' is accepted, allowing an unconditional PUT request. 

689 The server determines PUT behavior, so other modes are rejected 

690 defensively rather than assuming append or create semantics. 

691 **kwargs : dict 

692 Additional parameters to pass to the HTTP request 

693 """ 

694 if mode != "overwrite": 

695 raise ValueError( 

696 "HTTP pipe_file only accepts mode='overwrite'; " 

697 "PUT behavior is server-dependent, so other modes are " 

698 "rejected defensively" 

699 ) 

700 url = self._strip_protocol(path) 

701 headers = kwargs.pop("headers", {}) 

702 headers["Content-Length"] = str(len(value)) 

703 

704 session = await self.set_session() 

705 

706 async with session.put( 

707 self.encode_url(url), data=value, headers=headers, **kwargs 

708 ) as r: 

709 r.raise_for_status() 

710 

711 

712class HTTPFile(AbstractBufferedFile): 

713 """ 

714 A file-like object pointing to a remote HTTP(S) resource 

715 

716 Supports only reading, with read-ahead of a predetermined block-size. 

717 

718 In the case that the server does not supply the filesize, only reading of 

719 the complete file in one go is supported. 

720 

721 Parameters 

722 ---------- 

723 url: str 

724 Full URL of the remote resource, including the protocol 

725 session: aiohttp.ClientSession or None 

726 All calls will be made within this session, to avoid restarting 

727 connections where the server allows this 

728 block_size: int or None 

729 The amount of read-ahead to do, in bytes. Default is 5MB, or the value 

730 configured for the FileSystem creating this file 

731 size: None or int 

732 If given, this is the size of the file in bytes, and we don't attempt 

733 to call the server to find the value. 

734 kwargs: all other key-values are passed to requests calls. 

735 """ 

736 

737 def __init__( 

738 self, 

739 fs, 

740 url, 

741 session=None, 

742 block_size=None, 

743 mode="rb", 

744 cache_type="bytes", 

745 cache_options=None, 

746 size=None, 

747 loop=None, 

748 asynchronous=False, 

749 **kwargs, 

750 ): 

751 if mode != "rb": 

752 raise NotImplementedError("File mode not supported") 

753 self.asynchronous = asynchronous 

754 self.loop = loop 

755 self.url = url 

756 self.session = session 

757 self.details = {"name": url, "size": size, "type": "file"} 

758 super().__init__( 

759 fs=fs, 

760 path=url, 

761 mode=mode, 

762 block_size=block_size, 

763 cache_type=cache_type, 

764 cache_options=cache_options, 

765 **kwargs, 

766 ) 

767 

768 def read(self, length=-1): 

769 """Read bytes from file 

770 

771 Parameters 

772 ---------- 

773 length: int or None 

774 Read up to this many bytes. If negative or None, read all content to end of 

775 file. If the server has not supplied the filesize, attempting to 

776 read only part of the data will raise a ValueError. 

777 """ 

778 length = -1 if length is None else length 

779 if ( 

780 (length < 0 and self.loc == 0) # explicit read all 

781 # but not when the size is known and fits into a block anyways 

782 and not (self.size is not None and self.size <= self.blocksize) 

783 ): 

784 self._fetch_all() 

785 if self.size is None: 

786 if length < 0: 

787 self._fetch_all() 

788 else: 

789 length = min(self.size - self.loc, length) 

790 return super().read(length) 

791 

792 async def async_fetch_all(self): 

793 """Read whole file in one shot, without caching 

794 

795 This is only called when position is still at zero, 

796 and read() is called without a byte-count. 

797 """ 

798 logger.debug(f"Fetch all for {self}") 

799 if not isinstance(self.cache, AllBytes): 

800 r = await self.session.get(self.fs.encode_url(self.url), **self.kwargs) 

801 async with r: 

802 r.raise_for_status() 

803 out = await r.read() 

804 self.cache = AllBytes( 

805 size=len(out), fetcher=None, blocksize=None, data=out 

806 ) 

807 self.size = len(out) 

808 

809 _fetch_all = sync_wrapper(async_fetch_all) 

810 

811 async def async_fetch_range(self, start, end): 

812 """Download a block of data 

813 

814 The expectation is that the server returns only the requested bytes, 

815 with HTTP code 206. If this is not the case, we first check the headers, 

816 and then stream the output - if the data size is bigger than we 

817 requested, an exception is raised. 

818 """ 

819 logger.debug(f"Fetch range for {self}: {start}-{end}") 

820 kwargs = self.kwargs.copy() 

821 headers = kwargs.pop("headers", {}).copy() 

822 headers["Range"] = f"bytes={start}-{end - 1}" 

823 if not any(k.lower() == "accept-encoding" for k in headers) and not any( 

824 k.lower() == "accept-encoding" for k in self.session.headers 

825 ): 

826 # Match the unencoded size from _file_info unless explicitly overridden. 

827 headers["Accept-Encoding"] = "identity" 

828 logger.debug(f"{self.url} : {headers['Range']}") 

829 

830 async def _once(): 

831 r = await self.session.get( 

832 self.fs.encode_url(self.url), headers=headers, **kwargs 

833 ) 

834 async with r: 

835 if r.status == 416: 

836 if self.size is not None and start < self.size: 

837 # CloudFront answers 416 (with an HTML error page as 

838 # body) to a range inside the file when range requests 

839 # follow each other too quickly; treating it as EOF 

840 # would silently truncate the read, retrying with 

841 # backoff gets the data. See 

842 # https://github.com/fsspec/filesystem_spec/issues/1895 

843 raise aiohttp.ClientPayloadError( 

844 f"{headers['Range']} of {self.url} reported " 

845 f"unsatisfiable, but the file has {self.size} bytes" 

846 ) 

847 # range request outside file 

848 return b"" 

849 r.raise_for_status() 

850 

851 # If the server has handled the range request, it should reply 

852 # with status 206 (partial content). But we'll guess that a 

853 # suitable Content-Range header or a Content-Length no more 

854 # than the requested range also mean we have got the desired 

855 # range. 

856 response_is_range = response_is_requested_range( 

857 r.status, r.headers, start, end 

858 ) 

859 

860 if response_is_range: 

861 # partial content, as expected 

862 out = await r.read() 

863 if self.size is not None: 

864 expected = min(end, self.size) - start 

865 if len(out) < expected: 

866 # short body with consistent headers: a truncated 

867 # block would otherwise be served from the cache 

868 raise aiohttp.ClientPayloadError( 

869 f"{headers['Range']} of {self.url} returned " 

870 f"{len(out)} bytes, expected {expected}" 

871 ) 

872 elif start > 0: 

873 raise ValueError( 

874 "The HTTP server doesn't appear to support range " 

875 "requests. Only reading this file from the beginning " 

876 "is supported. Open with block_size=0 for a streaming " 

877 "file interface." 

878 ) 

879 else: 

880 # Response is not a range, but we want the start of the 

881 # file, so we can read the required amount anyway. 

882 cl = 0 

883 out = [] 

884 while True: 

885 chunk = await r.content.read(2**20) 

886 # data size unknown, let's read until we have enough 

887 if chunk: 

888 out.append(chunk) 

889 cl += len(chunk) 

890 if cl > end - start: 

891 break 

892 else: 

893 break 

894 out = b"".join(out)[: end - start] 

895 return out 

896 

897 return await _with_retries(_once, self.fs, f"{self.url} ({headers['Range']})") 

898 

899 _fetch_range = sync_wrapper(async_fetch_range) 

900 

901 

902magic_check = re.compile("([*[])") 

903 

904 

905def has_magic(s): 

906 match = magic_check.search(s) 

907 return match is not None 

908 

909 

910class HTTPStreamFile(AbstractBufferedFile): 

911 def __init__(self, fs, url, mode="rb", loop=None, session=None, **kwargs): 

912 self.asynchronous = kwargs.pop("asynchronous", False) 

913 self.url = url 

914 self.loop = loop 

915 self.session = session 

916 if mode != "rb": 

917 raise ValueError 

918 self.details = {"name": url, "size": None} 

919 super().__init__(fs=fs, path=url, mode=mode, cache_type="none", **kwargs) 

920 

921 async def cor(): 

922 r = await self.session.get(self.fs.encode_url(url), **kwargs).__aenter__() 

923 self.fs._raise_not_found_for_status(r, url) 

924 return r 

925 

926 self.r = sync(self.loop, cor) 

927 self.loop = fs.loop 

928 self.leftover = b"" 

929 self._last_read = b"" 

930 

931 def seek(self, loc, whence=0): 

932 """Allow delimiter lookahead to rewind at most one block.""" 

933 if self.closed: 

934 raise ValueError("I/O operation on closed file.") 

935 if whence == 1: 

936 loc += self.loc 

937 elif whence != 0: 

938 raise ValueError("Cannot seek streaming HTTP file") 

939 if loc == self.loc: 

940 return self.loc 

941 rewind = self.loc - loc 

942 if 0 < rewind <= len(self._last_read): 

943 self.leftover = self._last_read[-rewind:] + self.leftover 

944 self._last_read = self._last_read[:-rewind] 

945 self.loc = loc 

946 return self.loc 

947 raise ValueError("Cannot seek streaming HTTP file") 

948 

949 async def _read(self, num=-1): 

950 if self.closed: 

951 raise ValueError("I/O operation on closed file.") 

952 num = -1 if num is None else num 

953 if num < 0: 

954 out = self.leftover + await self.r.content.read(num) 

955 self.leftover = b"" 

956 elif len(self.leftover) >= num: 

957 out, self.leftover = self.leftover[:num], self.leftover[num:] 

958 else: 

959 out = self.leftover + await self.r.content.read(num - len(self.leftover)) 

960 self.leftover = b"" 

961 if num: 

962 # Retain at most one block, even after a large bounded read. 

963 self._last_read = out[-self.blocksize :] if num > 0 else b"" 

964 self.loc += len(out) 

965 return out 

966 

967 read = sync_wrapper(_read) 

968 

969 def readuntil(self, char=b"\n", blocks=None): 

970 # The unused delimiter lookahead must fit in the rewind buffer. 

971 return super().readuntil(char, min(blocks or self.blocksize, self.blocksize)) 

972 

973 async def _close(self): 

974 self.r.close() 

975 

976 def close(self): 

977 asyncio.run_coroutine_threadsafe(self._close(), self.loop) 

978 self.leftover = self._last_read = b"" 

979 super().close() 

980 

981 

982class AsyncStreamFile(AbstractAsyncStreamedFile): 

983 def __init__( 

984 self, fs, url, mode="rb", loop=None, session=None, size=None, **kwargs 

985 ): 

986 self.url = url 

987 self.session = session 

988 self.r = None 

989 if mode != "rb": 

990 raise ValueError 

991 self.details = {"name": url, "size": None} 

992 self.kwargs = kwargs 

993 super().__init__(fs=fs, path=url, mode=mode, cache_type="none") 

994 self.size = size 

995 

996 def seek(self, loc, whence=0): 

997 if loc == 0 and whence == 1: 

998 return self.loc 

999 if loc == self.loc and whence == 0: 

1000 return self.loc 

1001 raise ValueError("Cannot seek streaming HTTP file") 

1002 

1003 def seekable(self): 

1004 return False 

1005 

1006 async def read(self, num=-1): 

1007 if self.closed: 

1008 raise ValueError("I/O operation on closed file.") 

1009 num = -1 if num is None else num 

1010 if self.r is None: 

1011 r = await self.session.get( 

1012 self.fs.encode_url(self.url), **self.kwargs 

1013 ).__aenter__() 

1014 self.fs._raise_not_found_for_status(r, self.url) 

1015 self.r = r 

1016 out = await self.r.content.read(num) 

1017 self.loc += len(out) 

1018 return out 

1019 

1020 async def close(self): 

1021 if self.r is not None: 

1022 self.r.close() 

1023 self.r = None 

1024 await super().close() 

1025 

1026 

1027async def get_range(session, url, start, end, file=None, **kwargs): 

1028 # explicit get a range when we know it must be safe 

1029 kwargs = kwargs.copy() 

1030 headers = kwargs.pop("headers", {}).copy() 

1031 headers["Range"] = f"bytes={start}-{end - 1}" 

1032 r = await session.get(url, headers=headers, **kwargs) 

1033 r.raise_for_status() 

1034 async with r: 

1035 out = await r.read() 

1036 if file: 

1037 with open(file, "r+b") as f: # noqa: ASYNC230 

1038 f.seek(start) 

1039 f.write(out) 

1040 else: 

1041 return out 

1042 

1043 

1044async def _file_info(url, session, size_policy="head", **kwargs): 

1045 """Call HEAD on the server to get details about the file (size/checksum etc.) 

1046 

1047 Default operation is to explicitly allow redirects and use encoding 

1048 'identity' (no compression) to get the true size of the target. 

1049 """ 

1050 logger.debug("Retrieve file size for %s", url) 

1051 kwargs = kwargs.copy() 

1052 ar = kwargs.pop("allow_redirects", True) 

1053 head = kwargs.get("headers", {}).copy() 

1054 head["Accept-Encoding"] = "identity" 

1055 kwargs["headers"] = head 

1056 

1057 info = {} 

1058 if size_policy == "head": 

1059 r = await session.head(url, allow_redirects=ar, **kwargs) 

1060 elif size_policy == "get": 

1061 r = await session.get(url, allow_redirects=ar, **kwargs) 

1062 else: 

1063 raise TypeError(f'size_policy must be "head" or "get", got {size_policy}') 

1064 async with r: 

1065 r.raise_for_status() 

1066 

1067 if "Content-Length" in r.headers: 

1068 # Some servers may choose to ignore Accept-Encoding and return 

1069 # compressed content, in which case the returned size is unreliable. 

1070 if "Content-Encoding" not in r.headers or r.headers["Content-Encoding"] in [ 

1071 "identity", 

1072 "", 

1073 ]: 

1074 info["size"] = int(r.headers["Content-Length"]) 

1075 elif "Content-Range" in r.headers: 

1076 info["size"] = int(r.headers["Content-Range"].split("/")[1]) 

1077 

1078 if "Content-Type" in r.headers: 

1079 info["mimetype"] = r.headers["Content-Type"].partition(";")[0] 

1080 

1081 if r.headers.get("Accept-Ranges") == "none": 

1082 # Some servers may explicitly discourage partial content requests, but 

1083 # the lack of "Accept-Ranges" does not always indicate they would fail 

1084 info["partial"] = False 

1085 

1086 info["url"] = str(r.url) 

1087 

1088 for checksum_field in ["ETag", "Content-MD5", "Digest", "Last-Modified"]: 

1089 if r.headers.get(checksum_field): 

1090 info[checksum_field] = r.headers[checksum_field] 

1091 

1092 return info 

1093 

1094 

1095async def _file_size(url, session=None, *args, **kwargs): 

1096 if session is None: 

1097 session = await get_client() 

1098 info = await _file_info(url, session=session, *args, **kwargs) 

1099 return info.get("size") 

1100 

1101 

1102file_size = sync_wrapper(_file_size)