Coverage for python/lsst/resources/http.py: 22%

764 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 03:18 -0700

1# This file is part of lsst-resources. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14__all__ = ("HttpResourcePath",) 

15 

16import contextlib 

17import enum 

18import functools 

19import io 

20import json 

21import logging 

22import math 

23import os 

24import os.path 

25import random 

26import re 

27import ssl 

28import stat 

29from collections.abc import Iterator 

30from typing import TYPE_CHECKING, Any, BinaryIO, cast 

31 

32try: 

33 # Prefer 'defusedxml' (not part of standard library) if available, since 

34 # 'xml' is vulnerable to XML bombs. 

35 import defusedxml.ElementTree as eTree 

36except ImportError: 

37 import xml.etree.ElementTree as eTree 

38 

39try: 

40 import fsspec 

41 from aiohttp import ClientSession, ClientTimeout, TCPConnector 

42 from fsspec.implementations.http import HTTPFileSystem 

43 from fsspec.spec import AbstractFileSystem 

44except ImportError: 

45 fsspec = None 

46 AbstractFileSystem = type 

47 HTTPFileSystem = type 

48 

49from urllib.parse import parse_qs 

50 

51import requests 

52from astropy import units as u 

53from requests.adapters import HTTPAdapter 

54from requests.auth import AuthBase 

55from urllib3.util.retry import Retry 

56 

57from lsst.utils.timer import time_this 

58 

59from ._resourceHandles import ResourceHandleProtocol 

60from ._resourceHandles._httpResourceHandle import HttpReadResourceHandle, parse_content_range_header 

61from ._resourcePath import ResourcePath 

62from .utils import _get_num_workers, get_tempdir 

63 

64if TYPE_CHECKING: 

65 from .utils import TransactionProtocol 

66 

67log = logging.getLogger(__name__) 

68 

69 

70def _timeout_from_environment(env_var: str, default_value: float) -> float: 

71 """Convert and return a timeout from the value of an environment variable 

72 or a default value if the environment variable is not initialized. The 

73 value of `env_var` must be a valid `float` otherwise this function raises. 

74 

75 Parameters 

76 ---------- 

77 env_var : `str` 

78 Environment variable to look for. 

79 default_value : `float`` 

80 Value to return if `env_var` is not defined in the environment. 

81 

82 Returns 

83 ------- 

84 _timeout_from_environment : `float` 

85 Converted value. 

86 """ 

87 try: 

88 timeout = float(os.environ.get(env_var, default_value)) 

89 except ValueError: 

90 raise ValueError( 

91 f"Expecting valid timeout value in environment variable {env_var} but found " 

92 f"{os.environ.get(env_var)}" 

93 ) from None 

94 

95 if math.isnan(timeout): 

96 raise ValueError(f"Unexpected timeout value NaN found in environment variable {env_var}") 

97 

98 return timeout 

99 

100 

101@functools.lru_cache 

102def _calc_tmpdir_buffer_size(tmpdir: str) -> int: 

103 """Compute the block size as 256 blocks of typical size 

104 (i.e. 4096 bytes) or 10 times the file system block size, 

105 whichever is higher. 

106 

107 This is a reasonable compromise between 

108 using memory for buffering and the number of system calls 

109 issued to read from or write to temporary files. 

110 """ 

111 fsstats = os.statvfs(tmpdir) 

112 return max(10 * fsstats.f_bsize, 256 * 4096) 

113 

114 

115class HttpResourcePathConfig: 

116 """Configuration class to encapsulate the configurable items used by class 

117 HttpResourcePath. 

118 """ 

119 

120 # Default timeouts for all HTTP requests (seconds). 

121 DEFAULT_TIMEOUT_CONNECT: float = 60.0 

122 DEFAULT_TIMEOUT_READ: float = 1_500.0 

123 

124 # Default lower and upper bounds for the backoff interval (seconds). 

125 # A value in this interval is randomly selected as the backoff factor when 

126 # requests need to be retried. 

127 DEFAULT_BACKOFF_MIN: float = 1.0 

128 DEFAULT_BACKOFF_MAX: float = 3.0 

129 

130 # Default number of connections to persist with both the front end and 

131 # back end servers. 

132 DEFAULT_FRONTEND_PERSISTENT_CONNECTIONS: int = 2 

133 DEFAULT_BACKEND_PERSISTENT_CONNECTIONS: int = 1 

134 

135 # Accepted digest algorithms 

136 ACCEPTED_DIGESTS: list[str] = ["adler32", "md5", "sha-256", "sha-512"] 

137 

138 def __init__(self) -> None: 

139 self._front_end_connections: int | None = None 

140 self._back_end_connections: int | None = None 

141 self._digest_algorithm: str | None = None 

142 self._send_expect_on_put: bool | None = None 

143 self._fsspec_is_enabled: bool | None = None 

144 self._timeout: tuple[float, float] | None = None 

145 self._collect_memory_usage: bool | None = None 

146 self._backoff_min: float | None = None 

147 self._backoff_max: float | None = None 

148 self._ca_bundle: str | None = "" 

149 self._client_token: str | None = "" 

150 self._client_cert: str | None = "" 

151 self._client_key: str | None = "" 

152 self._tmpdir_buffersize: tuple[str, int] | None = None 

153 self._ssl_context: ssl.SSLContext | None = None 

154 

155 @property 

156 def front_end_connections(self) -> int: 

157 """Number of persistent connections to the front end server.""" 

158 if self._front_end_connections is not None: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true

159 return self._front_end_connections 

160 

161 default_pool_size = max(_get_num_workers(), self.DEFAULT_FRONTEND_PERSISTENT_CONNECTIONS) 

162 

163 try: 

164 self._front_end_connections = int( 

165 os.environ.get("LSST_HTTP_FRONTEND_PERSISTENT_CONNECTIONS", default_pool_size) 

166 ) 

167 except ValueError: 

168 self._front_end_connections = default_pool_size 

169 

170 return self._front_end_connections 

171 

172 @property 

173 def back_end_connections(self) -> int: 

174 """Number of persistent connections to the back end servers.""" 

175 if self._back_end_connections is not None: 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true

176 return self._back_end_connections 

177 

178 default_pool_size = max(_get_num_workers(), self.DEFAULT_FRONTEND_PERSISTENT_CONNECTIONS) 

179 

180 try: 

181 self._back_end_connections = int( 

182 os.environ.get("LSST_HTTP_BACKEND_PERSISTENT_CONNECTIONS", default_pool_size) 

183 ) 

184 except ValueError: 

185 self._back_end_connections = default_pool_size 

186 

187 return self._back_end_connections 

188 

189 @property 

190 def digest_algorithm(self) -> str: 

191 """Algorithm to ask the server to use for computing and recording 

192 digests of each file contents in PUT requests. 

193 

194 Returns 

195 ------- 

196 digest_algorithm: `str` 

197 The name of a digest algorithm or the empty string if no algotihm 

198 is configured. 

199 """ 

200 if self._digest_algorithm is not None: 

201 return self._digest_algorithm 

202 

203 digest = os.environ.get("LSST_HTTP_DIGEST", "").lower() 

204 if digest not in self.ACCEPTED_DIGESTS: 

205 digest = "" 

206 

207 self._digest_algorithm = digest 

208 return self._digest_algorithm 

209 

210 @property 

211 def send_expect_on_put(self) -> bool: 

212 """Return True if a "Expect: 100-continue" header is to be sent to 

213 the server on each PUT request. 

214 

215 Some servers (e.g. dCache) uses this information as an indication that 

216 the client knows how to handle redirects to the specific server that 

217 will actually receive the data for PUT requests. 

218 """ 

219 if self._send_expect_on_put is not None: 

220 return self._send_expect_on_put 

221 

222 self._send_expect_on_put = "LSST_HTTP_PUT_SEND_EXPECT_HEADER" in os.environ 

223 return self._send_expect_on_put 

224 

225 @property 

226 def fsspec_is_enabled(self) -> bool: 

227 """Return True if `fsspec` is enabled for objects of class 

228 HttpResourcePath. 

229 

230 To determine if `fsspec` is enabled, this method inspects the presence 

231 of the environment variable `LSST_HTTP_ENABLE_FSSPEC` (with any value). 

232 """ 

233 if self._fsspec_is_enabled is not None: 

234 return self._fsspec_is_enabled 

235 

236 self._fsspec_is_enabled = "LSST_HTTP_ENABLE_FSSPEC" in os.environ 

237 return self._fsspec_is_enabled 

238 

239 @property 

240 def timeout(self) -> tuple[float, float]: 

241 """Return a tuple with the values of timeouts for connecting to the 

242 server and reading its response, respectively. Both values are in 

243 seconds. 

244 """ 

245 if self._timeout is not None: 

246 return self._timeout 

247 

248 self._timeout = ( 

249 _timeout_from_environment("LSST_HTTP_TIMEOUT_CONNECT", self.DEFAULT_TIMEOUT_CONNECT), 

250 _timeout_from_environment("LSST_HTTP_TIMEOUT_READ", self.DEFAULT_TIMEOUT_READ), 

251 ) 

252 return self._timeout 

253 

254 @property 

255 def collect_memory_usage(self) -> bool: 

256 """Return true if we want to collect memory usage when timing 

257 operations against the remote server via the `lsst.utils.time_this` 

258 context manager. 

259 """ 

260 if self._collect_memory_usage is not None: 

261 return self._collect_memory_usage 

262 

263 self._collect_memory_usage = "LSST_HTTP_COLLECT_MEMORY_USAGE" in os.environ 

264 return self._collect_memory_usage 

265 

266 @property 

267 def backoff_min(self) -> float: 

268 """Lower bound of the interval from which a backoff factor is randomly 

269 selected when retrying requests (seconds). 

270 """ 

271 if self._backoff_min is not None: 

272 return self._backoff_min 

273 

274 self._backoff_min = self.DEFAULT_BACKOFF_MIN 

275 try: 

276 backoff_min = float(os.environ.get("LSST_HTTP_BACKOFF_MIN", self.DEFAULT_BACKOFF_MIN)) 

277 if not math.isnan(backoff_min): 277 ↛ 282line 277 didn't jump to line 282 because the condition on line 277 was always true

278 self._backoff_min = backoff_min 

279 except ValueError: 

280 pass 

281 

282 return self._backoff_min 

283 

284 @property 

285 def backoff_max(self) -> float: 

286 """Upper bound of the interval from which a backoff factor is randomly 

287 selected when retrying requests (seconds). 

288 """ 

289 if self._backoff_max is not None: 

290 return self._backoff_max 

291 

292 self._backoff_max = self.DEFAULT_BACKOFF_MAX 

293 try: 

294 backoff_max = float(os.environ.get("LSST_HTTP_BACKOFF_MAX", self.DEFAULT_BACKOFF_MAX)) 

295 if not math.isnan(backoff_max): 295 ↛ 300line 295 didn't jump to line 300 because the condition on line 295 was always true

296 self._backoff_max = backoff_max 

297 except ValueError: 

298 pass 

299 

300 return self._backoff_max 

301 

302 @property 

303 def ca_bundle(self) -> str | None: 

304 """Local path to the certificate bundle file or directory where the 

305 certifcates of the trusted authorities are located. 

306 

307 Return None if this host's system certificate bundle should be 

308 used for authenticating remote servers' certificates. 

309 """ 

310 if self._ca_bundle != "": 

311 return self._ca_bundle 

312 

313 # If a bundle was specified via the environment variable 

314 # 'LSST_HTTP_CACERT_BUNDLE' use it. 

315 self._ca_bundle = os.getenv("LSST_HTTP_CACERT_BUNDLE") 

316 return self._ca_bundle 

317 

318 @property 

319 def client_token(self) -> str | None: 

320 """Value of a bearer token or path to a local file which contains 

321 the bearer token to use for authenticating the client when sending 

322 requests to the webDAV or HTTP server. 

323 

324 Return None if no bearer token is configured in the environment. 

325 """ 

326 if self._client_token != "": 

327 return self._client_token 

328 

329 # If environment variable LSST_HTTP_AUTH_BEARER_TOKEN is 

330 # initialized use its value as the bearer token. 

331 self._client_token = os.getenv("LSST_HTTP_AUTH_BEARER_TOKEN") 

332 return self._client_token 

333 

334 @property 

335 def client_cert_key(self) -> tuple[str | None, str | None]: 

336 """Paths to a local file where the client certificate and associated 

337 private key are located. 

338 

339 Return a tuple (client certificate, private key) or (None, None) if no 

340 client certificate is configured via environment variables. 

341 """ 

342 if self._client_cert != "" and self._client_key != "": 

343 return (self._client_cert, self._client_key) 

344 

345 # If the environment variables LSST_HTTP_AUTH_CLIENT_CERT 

346 # and LSST_HTTP_AUTH_CLIENT_KEY are initialized use their values. 

347 self._client_cert = os.getenv("LSST_HTTP_AUTH_CLIENT_CERT") 

348 self._client_key = os.getenv("LSST_HTTP_AUTH_CLIENT_KEY") 

349 if self._client_cert and self._client_key: 

350 if not _is_protected(self._client_key): 

351 raise PermissionError( 

352 f"Private key file at {self._client_key} must be protected for access only by its owner" 

353 ) 

354 return (self._client_cert, self._client_key) 

355 

356 # If only the certificate was provided raise. 

357 if self._client_cert: 

358 raise ValueError( 

359 "Environment variable LSST_HTTP_AUTH_CLIENT_KEY must be set to client private key file path" 

360 ) 

361 

362 # If only the private key was provided raise. 

363 if self._client_key: 

364 raise ValueError( 

365 "Environment variable LSST_HTTP_AUTH_CLIENT_CERT must be set to client certificate file path" 

366 ) 

367 

368 # If a X.509 user proxy is available, use it as client credentials. 

369 self._client_cert = self._client_key = os.getenv("X509_USER_PROXY") 

370 return (self._client_cert, self._client_key) 

371 

372 @property 

373 def tmpdir_buffersize(self) -> tuple[str, int]: 

374 """Return the path to a temporary directory and the preferred buffer 

375 size to use when reading or writing files in that directory. 

376 """ 

377 if self._tmpdir_buffersize is not None: 

378 return self._tmpdir_buffersize 

379 

380 tmpdir = get_tempdir() 

381 

382 # Compute the block size as 256 blocks of typical size 

383 # (i.e. 4096 bytes) or 10 times the file system block size, 

384 # whichever is higher. This is a reasonable compromise between 

385 # using memory for buffering and the number of system calls 

386 # issued to read from or write to temporary files. 

387 bufsize = _calc_tmpdir_buffer_size(tmpdir) 

388 self._tmpdir_buffersize = (tmpdir, bufsize) 

389 

390 return self._tmpdir_buffersize 

391 

392 @property 

393 def ssl_context(self) -> ssl.SSLContext: 

394 """Return an SSL context equiped with the certificates of the trusted 

395 authorities. 

396 """ 

397 if self._ssl_context is None: 

398 self._ssl_context = ssl.create_default_context() 

399 if self.ca_bundle is not None: 

400 if os.path.isdir(self.ca_bundle): 

401 self._ssl_context.load_verify_locations(capath=self.ca_bundle) 

402 elif os.path.isfile(self.ca_bundle): 

403 self._ssl_context.load_verify_locations(cafile=self.ca_bundle) 

404 

405 return self._ssl_context 

406 

407 

408@functools.lru_cache 

409def _get_dav_and_server_headers(path: ResourcePath | str) -> tuple[str | None, str | None]: 

410 """Retrieve the "DAV" and "Server" headers sent by the remote server as 

411 part of the response to a single "OPTIONS" HTTP request. 

412 

413 Parameters 

414 ---------- 

415 path : `ResourcePath` or `str` 

416 URL to the resource to be checked. 

417 Should preferably refer to the root since the status is shared 

418 by all paths in that server. 

419 

420 Returns 

421 ------- 

422 _get_dav_and_server_headers : `tuple[str|None, str|None]` 

423 Values of the "DAV" and "Server" headers found in the response or 

424 None if any of those headers was not part of the response. 

425 """ 

426 try: 

427 if not isinstance(path, HttpResourcePath): 

428 path = HttpResourcePath(path) 

429 

430 config = HttpResourcePathConfig() 

431 with SessionStore(config=config).get(path) as session: 

432 resp = session.options( 

433 str(path), stream=False, timeout=config.timeout, headers=path._extra_headers 

434 ) 

435 

436 dav_header = server_header = None 

437 if resp.status_code == requests.codes.ok: 

438 dav_header = resp.headers.get("DAV") if "DAV" in resp.headers else None 

439 server_header = resp.headers.get("Server") if "Server" in resp.headers else None 

440 

441 return (dav_header, server_header) 

442 

443 except requests.exceptions.SSLError as e: 

444 log.warning( 

445 "Environment variable LSST_HTTP_CACERT_BUNDLE can be used to " 

446 "specify the path to a bundle of certificate authorities you trust " 

447 "which are not included in the default set of trusted authorities " 

448 "of this system." 

449 ) 

450 raise e 

451 

452 

453class BearerTokenAuth(AuthBase): 

454 """Attach a bearer token 'Authorization' header to each request. 

455 

456 Parameters 

457 ---------- 

458 token : `str` 

459 Can be either the path to a local protected file which contains the 

460 value of the token or the token itself. 

461 """ 

462 

463 def __init__(self, token: str): 

464 self._token = self._path = None 

465 self._mtime: float = -1.0 

466 if not token: 

467 return 

468 

469 self._token = token 

470 if os.path.isfile(token): 

471 self._path = os.path.abspath(token) 

472 if not _is_protected(self._path): 

473 raise PermissionError( 

474 f"Bearer token file at {self._path} must be protected for access only by its owner" 

475 ) 

476 self._refresh() 

477 

478 def _refresh(self) -> None: 

479 """Read the token file (if any) if its modification time is more recent 

480 than the last time we read it. 

481 """ 

482 if not self._path: 

483 return 

484 

485 if (mtime := os.stat(self._path).st_mtime) > self._mtime: 

486 log.debug("Reading bearer token file at %s", self._path) 

487 self._mtime = mtime 

488 with open(self._path) as f: 

489 self._token = f.read().rstrip("\n") 

490 

491 def __call__(self, req: requests.PreparedRequest) -> requests.PreparedRequest: 

492 # Only add a bearer token to a request when using secure HTTP. 

493 if req.url and req.url.lower().startswith("https://") and self._token: 

494 self._refresh() 

495 req.headers["Authorization"] = f"Bearer {self._token}" 

496 return req 

497 

498 

499class SessionStore: 

500 """Cache a reusable HTTP client session per endpoint. 

501 

502 Parameters 

503 ---------- 

504 config : `HttpResourcePathConfig` 

505 Configuration items shared by all instances of HttpResourcePath. 

506 num_pools : `int`, optional 

507 Number of connection pools to keep: there is one pool per remote 

508 host. 

509 max_persistent_connections : `int`, optional 

510 Maximum number of connections per remote host to persist in each 

511 connection pool. 

512 backoff_min : `float`, optional 

513 Minimum value of the interval to compute the exponential 

514 backoff factor when retrying requests (seconds). 

515 backoff_max : `float`, optional 

516 Maximum value of the interval to compute the exponential 

517 backoff factor when retrying requests (seconds). 

518 """ 

519 

520 def __init__( 

521 self, 

522 config: HttpResourcePathConfig, 

523 num_pools: int = 10, 

524 max_persistent_connections: int = 1, 

525 backoff_min: float = 1.0, 

526 backoff_max: float = 3.0, 

527 ) -> None: 

528 # Dictionary to store the session associated to a given URI. The key 

529 # of the dictionary is a root URI and the value is the session. 

530 self._sessions: dict[str, requests.Session] = {} 

531 

532 # Configuration for all instances of HttpResourcePath objects. 

533 self._config = config 

534 

535 # See documentation of urllib3 PoolManager class: 

536 # https://urllib3.readthedocs.io 

537 self._num_pools: int = num_pools 

538 

539 # See urllib3 Advanced Usage documentation: 

540 # https://urllib3.readthedocs.io/en/stable/advanced-usage.html 

541 self._max_persistent_connections: int = max_persistent_connections 

542 

543 # Minimum and maximum values of the interval to compute the exponential 

544 # backoff factor when retrying requests (seconds). 

545 self._backoff_min: float = backoff_min 

546 self._backoff_max: float = backoff_max if backoff_max > backoff_min else backoff_min + 1.0 

547 

548 def clear(self) -> None: 

549 """Destroy all previously created sessions and attempt to close 

550 underlying idle network connections. 

551 """ 

552 # Close all sessions and empty the store. Idle network connections 

553 # should be closed as a consequence. We don't have means through 

554 # the API exposed by Requests to actually force closing the 

555 # underlying open sockets. 

556 for session in self._sessions.values(): 

557 session.close() 

558 

559 self._sessions.clear() 

560 

561 def get(self, rpath: ResourcePath) -> requests.Session: 

562 """Retrieve a session for accessing the remote resource at rpath. 

563 

564 Parameters 

565 ---------- 

566 rpath : `ResourcePath` 

567 URL to a resource at the remote server for which a session is to 

568 be retrieved. 

569 

570 Notes 

571 ----- 

572 Once a session is created for a given endpoint it is cached and 

573 returned every time a session is requested for any path under that same 

574 endpoint. For instance, a single session will be cached and shared 

575 for paths "https://www.example.org/path/to/file" and 

576 "https://www.example.org/any/other/path". 

577 

578 Note that "https://www.example.org" and "https://www.example.org:12345" 

579 will have different sessions since the port number is not identical. 

580 """ 

581 root_uri = str(rpath.root_uri()) 

582 if root_uri not in self._sessions: 

583 # We don't have yet a session for this endpoint: create a new one. 

584 self._sessions[root_uri] = self._make_session(rpath) 

585 

586 return self._sessions[root_uri] 

587 

588 def _make_session(self, rpath: ResourcePath) -> requests.Session: 

589 """Make a new session configured from values from the environment.""" 

590 session = requests.Session() 

591 root_uri = str(rpath.root_uri()) 

592 log.debug("Creating new HTTP session for endpoint %s ...", root_uri) 

593 retries = Retry( 

594 # Total number of retries to allow. Takes precedence over other 

595 # counts. 

596 total=6, 

597 # How many connection-related errors to retry on. 

598 connect=3, 

599 # How many times to retry on read errors. 

600 read=3, 

601 # Backoff factor to apply between attempts after the second try 

602 # (seconds). Compute a random jitter to prevent all the clients 

603 # to overwhelm the server by sending requests at the same time. 

604 backoff_factor=self._backoff_min + (self._backoff_max - self._backoff_min) * random.random(), 

605 # How many times to retry on bad status codes. 

606 status=5, 

607 # Set of uppercased HTTP method verbs that we should retry on. 

608 # We only automatically retry idempotent requests. 

609 allowed_methods=frozenset( 

610 [ 

611 "COPY", 

612 "DELETE", 

613 "GET", 

614 "HEAD", 

615 "MKCOL", 

616 "OPTIONS", 

617 "PROPFIND", 

618 "PUT", 

619 ] 

620 ), 

621 # HTTP status codes that we should force a retry on. 

622 status_forcelist=frozenset( 

623 [ 

624 requests.codes.too_many_requests, # 429 

625 requests.codes.internal_server_error, # 500 

626 requests.codes.bad_gateway, # 502 

627 requests.codes.service_unavailable, # 503 

628 requests.codes.gateway_timeout, # 504 

629 ] 

630 ), 

631 # Whether to respect Retry-After header on status codes defined 

632 # above. 

633 respect_retry_after_header=True, 

634 ) 

635 

636 # Persist the specified number of connections to the front end server. 

637 session.mount( 

638 root_uri, 

639 HTTPAdapter( 

640 pool_connections=self._num_pools, 

641 pool_maxsize=self._max_persistent_connections, 

642 pool_block=False, 

643 max_retries=retries, 

644 ), 

645 ) 

646 

647 # Do not persist the connections to back end servers which may vary 

648 # from request to request. Systematically persisting connections to 

649 # those servers may exhaust their capabilities when there are thousands 

650 # of simultaneous clients. 

651 session.mount( 

652 f"{rpath.scheme}://", 

653 HTTPAdapter( 

654 pool_connections=self._num_pools, 

655 pool_maxsize=0, 

656 pool_block=False, 

657 max_retries=retries, 

658 ), 

659 ) 

660 

661 # If the remote endpoint doesn't use secure HTTP we don't include 

662 # bearer tokens in the requests nor need to authenticate the remote 

663 # server. 

664 if rpath.scheme != "https": 

665 return session 

666 

667 # Set the trusted CA certificates bundle for authenticating remote 

668 # servers. 

669 session.verify = True if self._config.ca_bundle is None else self._config.ca_bundle 

670 

671 # Should we use a bearer token for client authentication? 

672 if (token := self._config.client_token) is not None: 

673 log.debug("... using bearer token authentication") 

674 session.auth = BearerTokenAuth(token) 

675 return session 

676 

677 # Should we instead use client certificate and private key? 

678 client_cert, client_key = self._config.client_cert_key 

679 if client_cert and client_key: 

680 log.debug("... using client certificate authentication.") 

681 session.cert = (client_cert, client_key) 

682 return session 

683 

684 log.debug( 

685 "Neither LSST_HTTP_AUTH_BEARER_TOKEN nor (LSST_HTTP_AUTH_CLIENT_CERT and " 

686 "LSST_HTTP_AUTH_CLIENT_KEY) are initialized. Client authentication is disabled." 

687 ) 

688 return session 

689 

690 

691class ActivityCaveat(enum.Enum): 

692 """Helper class for enumerating accepted activity caveats for requesting 

693 macaroons. 

694 """ 

695 

696 DOWNLOAD = 1 

697 UPLOAD = 2 

698 

699 

700class HttpResourcePath(ResourcePath): 

701 """General HTTP(S) resource. 

702 

703 Notes 

704 ----- 

705 In order to configure the behavior of instances of this class, the 

706 environment variables below are inspected: 

707 

708 - LSST_HTTP_CACERT_BUNDLE: path to a .pem file or to a directory which 

709 contains the .pem files of the trusted certificate authorities's 

710 certificates. If the remote server presents a server certificate 

711 issued by one of those trusted authorities, we trust it. 

712 If this environment variable is not initialized, the default 

713 authorities of the the execution host are trusted. 

714 

715 - LSST_HTTP_AUTH_BEARER_TOKEN: value of a bearer token or path to a 

716 local file containing a bearer token to be used as the client 

717 authentication mechanism with all requests. 

718 The permissions of the token file must be set so that only its 

719 owner can access it. 

720 If initialized, takes precedence over LSST_HTTP_AUTH_CLIENT_CERT 

721 and LSST_HTTP_AUTH_CLIENT_KEY. 

722 

723 - LSST_HTTP_AUTH_CLIENT_CERT: path to a .pem file which contains the 

724 client certificate for authenticating to the server. 

725 If initialized, the variable LSST_HTTP_AUTH_CLIENT_KEY must also be 

726 initialized with the path of the client private key file. 

727 The permissions of the client private key must be set so that only 

728 its owner can access it, at least for reading. 

729 

730 - LSST_HTTP_PUT_SEND_EXPECT_HEADER: if set (with any value), a 

731 "Expect: 100-Continue" header will be added to all HTTP PUT requests. 

732 This header is required by some servers to detect if the client 

733 knows how to handle redirections. In case of redirection, the body 

734 of the PUT request is sent to the redirected location and not to 

735 the front end server. 

736 

737 - LSST_HTTP_TIMEOUT_CONNECT and LSST_HTTP_TIMEOUT_READ: if set to a 

738 numeric value, they are interpreted as the number of seconds to wait 

739 for establishing a connection with the server and for reading its 

740 response, respectively. 

741 

742 - LSST_HTTP_FRONTEND_PERSISTENT_CONNECTIONS and 

743 LSST_HTTP_BACKEND_PERSISTENT_CONNECTIONS: contain the maximum number 

744 of connections to attempt to persist with both the front end servers 

745 and the back end servers. 

746 Default values: DEFAULT_FRONTEND_PERSISTENT_CONNECTIONS and 

747 DEFAULT_BACKEND_PERSISTENT_CONNECTIONS. 

748 

749 - LSST_HTTP_DIGEST: case-insensitive name of the digest algorithm to 

750 ask the server to compute for every file's content sent to the server 

751 via a PUT request. No digest is requested if this variable is not set 

752 or is set to an invalid value. 

753 Valid values are those in ACCEPTED_DIGESTS. 

754 

755 - LSST_HTTP_ENABLE_FSSPEC: the presence of this environment variable 

756 activates the usage of `fsspec` compatible file system to read 

757 a HTTP URL. The value of the variable is not inspected. 

758 """ 

759 

760 @staticmethod 

761 def create_http_resource_path( 

762 path: str, *, extra_headers: dict[str, str] | None = None 

763 ) -> HttpResourcePath: 

764 """Create an instance of `HttpResourcePath` with additional 

765 HTTP-specific configuration. 

766 

767 Parameters 

768 ---------- 

769 path : `str` 

770 HTTP URL to be wrapped in a `ResourcePath` instance. 

771 extra_headers : `dict` [ `str`, `str` ], optional 

772 Additional headers that will be sent with every HTTP request made 

773 by this `ResourcePath`. These override any headers that may be 

774 generated internally by `HttpResourcePath` (e.g. authentication 

775 headers). 

776 

777 Return 

778 ------ 

779 instance : `ResourcePath` 

780 Newly-created `HttpResourcePath` instance. 

781 

782 Notes 

783 ----- 

784 Most users should use the `ResourcePath` constructor, instead. 

785 """ 

786 # Make sure we instantiate ResourcePath using a string to guarantee we 

787 # get a new ResourcePath. If we accidentally provided a ResourcePath 

788 # instance instead, the ResourcePath constructor sometimes returns 

789 # the original object and we would be modifying an object that is 

790 # supposed to be immutable. 

791 instance = ResourcePath(str(path)) 

792 assert isinstance(instance, HttpResourcePath) 

793 instance._extra_headers = extra_headers 

794 return instance 

795 

796 # WebDAV servers known to be able to sign URLs. The values are lowercased 

797 # server identifiers retrieved from the 'Server' header included in 

798 # the response to a HTTP OPTIONS request. 

799 SUPPORTED_URL_SIGNERS = ("dcache", "xrootd") 

800 

801 # Configuration items for this class instances. 

802 _config: HttpResourcePathConfig = HttpResourcePathConfig() 

803 

804 # The session for metadata requests is used for interacting with 

805 # the front end servers for requests such as PROPFIND, HEAD, etc. Those 

806 # interactions are typically served by the front end servers. We want to 

807 # keep the connection to the front end servers open, to reduce the cost 

808 # associated to TCP and TLS handshaking for each new request. 

809 _metadata_session_store = SessionStore( 

810 config=_config, 

811 num_pools=5, 

812 max_persistent_connections=_config.front_end_connections, 

813 backoff_min=_config.backoff_min, 

814 backoff_max=_config.backoff_max, 

815 ) 

816 

817 # The data session is used for interaction with the front end servers which 

818 # typically redirect to the back end servers for serving our PUT and GET 

819 # requests. We attempt to keep a single connection open with the front end 

820 # server, if possible. This depends on how the server behaves and the 

821 # kind of request. Some servers close the connection when redirecting 

822 # the client to a back end server, for instance when serving a PUT 

823 # request. 

824 _data_session_store = SessionStore( 

825 config=_config, 

826 num_pools=25, 

827 max_persistent_connections=_config.back_end_connections, 

828 backoff_min=_config.backoff_min, 

829 backoff_max=_config.backoff_max, 

830 ) 

831 

832 # Process ID which created the session stores above. We need to store this 

833 # to replace sessions created by a parent process and inherited by a 

834 # child process after a fork, to avoid confusing the SSL layer. 

835 _pid: int = -1 

836 

837 # Connector used by a session pool to establish network connections to 

838 # remote servers. This connector is exclusively used by fsspec file system 

839 # and is shared by all instances of this class. 

840 _tcp_connector: TCPConnector | None = None 

841 

842 # Additional headers added to every request. 

843 _extra_headers: dict[str, str] | None = None 

844 

845 @property 

846 def metadata_session(self) -> _SessionWrapper: 

847 """Client session to send requests which do not require upload or 

848 download of data, i.e. mostly metadata requests. 

849 """ 

850 session = None 

851 if hasattr(self, "_metadata_session"): 

852 if HttpResourcePath._pid == os.getpid(): 

853 session = self._metadata_session 

854 else: 

855 # The metadata session we have in cache was likely created by 

856 # a parent process. Discard all the sessions in that store. 

857 self._metadata_session_store.clear() 

858 

859 # Retrieve a new metadata session. 

860 if session is None: 

861 HttpResourcePath._pid = os.getpid() 

862 session = self._metadata_session_store.get(self) 

863 self._metadata_session: requests.Session = session 

864 return _SessionWrapper(session, extra_headers=self._extra_headers) 

865 

866 @property 

867 def data_session(self) -> _SessionWrapper: 

868 """Client session for uploading and downloading data.""" 

869 session = None 

870 if hasattr(self, "_data_session"): 

871 if HttpResourcePath._pid == os.getpid(): 

872 session = self._data_session 

873 else: 

874 # The data session we have in cache was likely created by 

875 # a parent process. Discard all the sessions in that store. 

876 self._data_session_store.clear() 

877 

878 # Retrieve a new data session. 

879 if session is None: 

880 HttpResourcePath._pid = os.getpid() 

881 session = self._data_session_store.get(self) 

882 self._data_session: requests.Session = session 

883 return _SessionWrapper(session, extra_headers=self._extra_headers) 

884 

885 def _clear_sessions(self) -> None: 

886 """Close the socket connections that are still open. 

887 

888 Used only in test suites to avoid warnings. 

889 """ 

890 self._metadata_session_store.clear() 

891 self._data_session_store.clear() 

892 

893 if hasattr(self, "_metadata_session"): 

894 delattr(self, "_metadata_session") 

895 

896 if hasattr(self, "_data_session"): 

897 delattr(self, "_data_session") 

898 

899 def _init_server_properties(self) -> None: 

900 """ 

901 Initialize instance variables '_is_webdav' and '_server' by 

902 sending a single OPTIONS request to the remote server and 

903 saving the results. 

904 """ 

905 # Retrieve the "DAV" and the "Server" headers for the root URL of this 

906 # path 

907 dav_header, server_header = _get_dav_and_server_headers(self.root_uri()) 

908 

909 # Check that "1" is part of the value of the "DAV" header. We don't 

910 # use locks, so a server complying to class 1 is enough for our 

911 # purposes. All webDAV servers must advertise at least compliance 

912 # class "1". 

913 # 

914 # Compliance classes are documented in 

915 # http://www.webdav.org/specs/rfc4918.html#dav.compliance.classes 

916 # 

917 # Examples of values for header DAV are: 

918 # DAV: 1, 2 

919 # DAV: 1, <http://apache.org/dav/propset/fs/1> 

920 self._is_webdav: bool = False 

921 if dav_header is not None: 

922 self._is_webdav = "1" in dav_header.replace(" ", "").split(",") 

923 

924 self._server: str | None = None 

925 if server_header is not None: 

926 # Server header is expected to be of the form 'dCache/9.2.4' 

927 # or 'XrootD/v5.7.1'. Strip version and put in lowercase. 

928 self._server = server_header.split("/")[0].lower() 

929 

930 @property 

931 def is_webdav_endpoint(self) -> bool: 

932 """Check if the current endpoint implements WebDAV features. 

933 

934 This is stored per URI but cached by root so there is only one check 

935 per hostname. 

936 """ 

937 if hasattr(self, "_is_webdav"): 

938 return self._is_webdav 

939 

940 self._init_server_properties() 

941 return self._is_webdav 

942 

943 @property 

944 def server(self) -> str | None: 

945 """Return the lowercased identifier of the remote server, retrieved 

946 from the response header 'Server' from an 'OPTIONS' HTTP request. 

947 

948 If the remote server does not include that header in its response 

949 to an 'OPTIONS' request, server() returns None. 

950 

951 Examples of return values are "dcache", "xrootd". 

952 """ 

953 if hasattr(self, "_server"): 

954 return self._server 

955 

956 self._init_server_properties() 

957 return self._server 

958 

959 @property 

960 def server_signs_urls(self) -> bool: 

961 """Return true if the remote server support signing or URLs for 

962 download and upload. 

963 """ 

964 return self.server in HttpResourcePath.SUPPORTED_URL_SIGNERS 

965 

966 @classmethod 

967 def _reload_config(cls) -> None: 

968 """Reload the configuration for all instances of this class. That 

969 configuration is instantiated from the environment. 

970 

971 This is an internal method mainly intended for tests. 

972 """ 

973 HttpResourcePath._config = HttpResourcePathConfig() 

974 

975 def exists(self) -> bool: 

976 """Check that a remote HTTP resource exists.""" 

977 log.debug("Checking if resource exists: %s", self.geturl()) 

978 if not self.is_webdav_endpoint: 

979 # The remote is a plain HTTP server. Let's attempt a HEAD 

980 # request, even if the behavior for such a request against a 

981 # directory is not specified, so it depends on the server 

982 # implementation. 

983 resp = self._head_non_webdav_url() 

984 return self._is_successful_non_webdav_head_request(resp) 

985 

986 # The remote endpoint is a webDAV server: send a PROPFIND request 

987 # to determine if it exists. 

988 resp = self._propfind() 

989 if resp.status_code == requests.codes.multi_status: # 207 

990 prop = _parse_propfind_response_body(resp.text)[0] 

991 return prop.exists 

992 else: # 404 Not Found 

993 return False 

994 

995 def size(self) -> int: 

996 """Return the size of the remote resource in bytes.""" 

997 if self.dirLike: 

998 return 0 

999 

1000 if not self.is_webdav_endpoint: 

1001 # The remote is a plain HTTP server. Send a HEAD request to 

1002 # retrieve the size of the resource. 

1003 resp = self._head_non_webdav_url() 

1004 if resp.status_code == requests.codes.ok: # 200 

1005 if "Content-Length" in resp.headers: 

1006 return int(resp.headers["Content-Length"]) 

1007 else: 

1008 raise ValueError( 

1009 f"Response to HEAD request to {self} does not contain 'Content-Length' header" 

1010 ) 

1011 elif resp.status_code == requests.codes.partial_content: 

1012 # 206 Partial Content, returned from a GET request with a Range 

1013 # header (used to emulate HEAD for presigned S3 URLs). 

1014 # In this case Content-Length is the length of the Range and 

1015 # not the full length of the file, so we have to parse 

1016 # Content-Range instead. 

1017 content_range_header = resp.headers.get("Content-Range") 

1018 if content_range_header is None: 

1019 raise ValueError( 

1020 f"Response to GET request to {self} did not contain 'Content-Range' header" 

1021 ) 

1022 content_range = parse_content_range_header(content_range_header) 

1023 size = content_range.total 

1024 if size is None: 

1025 raise ValueError(f"Content-Range header for {self} did not include a total file size") 

1026 return size 

1027 elif resp.status_code == requests.codes.range_not_satisfiable: 

1028 # 416 Range Not Satisfiable, which can occur on a GET for a 0 

1029 # byte file since we asked for 1 byte Range which is longer 

1030 # than the file. 

1031 # 

1032 # Servers are supposed to include a Content-Range header in 

1033 # this case, but Google's S3 implementation doesn't. Any 

1034 # non-zero file size should have been handled by the 206 and 

1035 # 200 cases above, so assume we have a zero here. 

1036 return 0 

1037 elif resp.status_code == requests.codes.not_found: 

1038 raise FileNotFoundError( 

1039 f"Resource {self} does not exist, status: {resp.status_code} {resp.reason}" 

1040 ) 

1041 else: 

1042 raise ValueError( 

1043 f"Unexpected response for HEAD request for {self}, status: {resp.status_code} " 

1044 f"{resp.reason}" 

1045 ) 

1046 

1047 # The remote is a webDAV server: send a PROPFIND request to retrieve 

1048 # the size of the resource. Sizes are only meaningful for files. 

1049 resp = self._propfind() 

1050 if resp.status_code == requests.codes.multi_status: # 207 

1051 prop = _parse_propfind_response_body(resp.text)[0] 

1052 if prop.is_file: 

1053 return prop.size 

1054 elif prop.is_directory: 

1055 raise IsADirectoryError( 

1056 f"Resource {self} is reported by server as a directory but has a file path" 

1057 ) 

1058 else: 

1059 raise FileNotFoundError(f"Resource {self} does not exist") 

1060 else: # 404 Not Found 

1061 raise FileNotFoundError( 

1062 f"Resource {self} does not exist, status: {resp.status_code} {resp.reason}" 

1063 ) 

1064 

1065 def _head_non_webdav_url(self) -> requests.Response: 

1066 """Return a response from a HTTP HEAD request for a non-WebDAV HTTP 

1067 URL. 

1068 

1069 Emulates HEAD using a 1-byte GET for presigned S3 URLs. 

1070 """ 

1071 if self._looks_like_presigned_s3_url(): 

1072 # Presigned S3 URLs are signed for a single method only, so you 

1073 # can't call HEAD on a URL signed for GET. However, S3 does 

1074 # support Range requests, so you can ask for a 1-byte range with 

1075 # GET for a similar effect to HEAD. 

1076 # 

1077 # Note that some headers differ between a true HEAD request and the 

1078 # response returned by this GET, e.g. Content-Length will always be 

1079 # 1, and the status code is 206 instead of 200. 

1080 return self.metadata_session.get( 

1081 self.geturl(), 

1082 timeout=self._config.timeout, 

1083 allow_redirects=True, 

1084 stream=False, 

1085 headers={"Range": "bytes=0-0"}, 

1086 ) 

1087 else: 

1088 return self.metadata_session.head( 

1089 self.geturl(), timeout=self._config.timeout, allow_redirects=True, stream=False 

1090 ) 

1091 

1092 def _is_successful_non_webdav_head_request(self, resp: requests.Response) -> bool: 

1093 """Return `True` if the status code in the response indicates a 

1094 successful response to ``_head_non_webdav_url``. 

1095 """ 

1096 return resp.status_code in ( 

1097 requests.codes.ok, # 200, from a normal HEAD or GET request 

1098 requests.codes.partial_content, # 206, returned from a GET request with a Range header. 

1099 # 416, returned from a GET request with a 1-byte Range header that 

1100 # is longer than the 0-byte file. 

1101 requests.codes.range_not_satisfiable, 

1102 ) 

1103 

1104 def _looks_like_presigned_s3_url(self) -> bool: 

1105 """Return `True` if this ResourcePath's URL is likely to be a presigned 

1106 S3 URL. 

1107 """ 

1108 query_params = parse_qs(self._uri.query) 

1109 return "Signature" in query_params and "Expires" in query_params 

1110 

1111 def mkdir(self) -> None: 

1112 """Create the directory resource if it does not already exist.""" 

1113 # Creating directories is only available on WebDAV back ends. 

1114 if not self.is_webdav_endpoint: 

1115 raise NotImplementedError( 

1116 f"Creation of directory {self} is not implemented by plain HTTP servers" 

1117 ) 

1118 

1119 if not self.dirLike: 

1120 raise NotADirectoryError(f"Can not create a 'directory' for file-like URI {self}") 

1121 

1122 # Check if the target directory already exists. 

1123 resp = self._propfind() 

1124 if resp.status_code == requests.codes.multi_status: # 207 

1125 prop = _parse_propfind_response_body(resp.text)[0] 

1126 if prop.exists: 

1127 if prop.is_directory: 

1128 return 

1129 else: 

1130 # A file exists at this path 

1131 raise NotADirectoryError( 

1132 f"Can not create a directory for {self} because a file already exists at that path" 

1133 ) 

1134 

1135 # Target directory does not exist. Create it and its ancestors as 

1136 # needed. We need to test if parent URL is different from self URL, 

1137 # otherwise we could be stuck in a recursive loop 

1138 # where self == parent. 

1139 if self.geturl() != self.parent().geturl(): 

1140 self.parent().mkdir() 

1141 

1142 log.debug("Creating new directory: %s", self.geturl()) 

1143 self._mkcol() 

1144 

1145 def remove(self) -> None: 

1146 """Remove the resource.""" 

1147 self._delete() 

1148 

1149 def read(self, size: int = -1) -> bytes: 

1150 """Open the resource and return the contents in bytes. 

1151 

1152 Parameters 

1153 ---------- 

1154 size : `int`, optional 

1155 The number of bytes to read. Negative or omitted indicates 

1156 that all data should be read. 

1157 """ 

1158 # Use the data session as a context manager to ensure that the 

1159 # network connections to both the front end and back end servers are 

1160 # closed after downloading the data. 

1161 log.debug("Reading from remote resource: %s", self.geturl()) 

1162 stream = size > 0 

1163 with self.data_session as session: 

1164 with time_this(log, msg="GET %s", args=(self,)): 

1165 resp = session.get(self.geturl(), stream=stream, timeout=self._config.timeout) 

1166 

1167 if resp.status_code != requests.codes.ok: # 200 

1168 raise FileNotFoundError( 

1169 f"Unable to read resource {self}; status: {resp.status_code} {resp.reason}" 

1170 ) 

1171 if not stream: 

1172 return resp.content 

1173 else: 

1174 return next(resp.iter_content(chunk_size=size)) 

1175 

1176 def write(self, data: bytes, overwrite: bool = True) -> None: 

1177 """Write the supplied bytes to the new resource. 

1178 

1179 Parameters 

1180 ---------- 

1181 data : `bytes` 

1182 The bytes to write to the resource. The entire contents of the 

1183 resource will be replaced. 

1184 overwrite : `bool`, optional 

1185 If `True` the resource will be overwritten if it exists. Otherwise 

1186 the write will fail. 

1187 """ 

1188 log.debug("Writing to remote resource: %s", self.geturl()) 

1189 if not overwrite and self.exists(): 

1190 raise FileExistsError(f"Remote resource {self} exists and overwrite has been disabled") 

1191 

1192 # Ensure the parent directory exists. 

1193 # This is only meaningful and appropriate for WebDAV, not the general 

1194 # HTTP case. e.g. for S3 HTTP URLs, the underlying service has no 

1195 # concept of 'directories' at all. 

1196 if self.is_webdav_endpoint: 

1197 self.parent().mkdir() 

1198 

1199 # Upload the data. 

1200 log.debug("Writing data to remote resource: %s", self.geturl()) 

1201 self._put(data=data) 

1202 

1203 def transfer_from( 

1204 self, 

1205 src: ResourcePath, 

1206 transfer: str = "copy", 

1207 overwrite: bool = False, 

1208 transaction: TransactionProtocol | None = None, 

1209 multithreaded: bool = True, 

1210 ) -> None: 

1211 """Transfer the current resource to a Webdav repository. 

1212 

1213 Parameters 

1214 ---------- 

1215 src : `ResourcePath` 

1216 Source URI. 

1217 transfer : `str` 

1218 Mode to use for transferring the resource. Supports the following 

1219 options: copy. 

1220 overwrite : `bool`, optional 

1221 Whether overwriting the remote resource is allowed or not. 

1222 transaction : `~lsst.resources.utils.TransactionProtocol`, optional 

1223 Currently unused. 

1224 multithreaded : `bool`, optional 

1225 If `True` the transfer will be allowed to attempt to improve 

1226 throughput by using parallel download streams. This may of no 

1227 effect if the URI scheme does not support parallel streams or 

1228 if a global override has been applied. If `False` parallel 

1229 streams will be disabled. 

1230 """ 

1231 # Fail early to prevent delays if remote resources are requested. 

1232 if transfer not in self.transferModes: 

1233 raise ValueError(f"Transfer mode {transfer} not supported by URI scheme {self.scheme}") 

1234 

1235 # Existence checks cost time so do not call this unless we know 

1236 # that debugging is enabled. 

1237 if log.isEnabledFor(logging.DEBUG): 

1238 log.debug( 

1239 "Transferring %s [exists: %s] -> %s [exists: %s] (transfer=%s)", 

1240 src, 

1241 src.exists(), 

1242 self, 

1243 self.exists(), 

1244 transfer, 

1245 ) 

1246 

1247 # Short circuit immediately if the URIs are identical. 

1248 if self == src: 

1249 log.debug( 

1250 "Target and destination URIs are identical: %s, returning immediately." 

1251 " No further action required.", 

1252 self, 

1253 ) 

1254 return 

1255 

1256 if not overwrite and self.exists(): 

1257 raise FileExistsError(f"Destination path {self} already exists.") 

1258 

1259 if transfer == "auto": 

1260 transfer = self.transferDefault 

1261 

1262 # We can use webDAV 'COPY' or 'MOVE' if both the current and source 

1263 # resources are located in the same server. 

1264 if isinstance(src, type(self)) and self.root_uri() == src.root_uri() and self.is_webdav_endpoint: 

1265 log.debug("Transfer from %s to %s directly", src, self) 

1266 return self._move(src) if transfer == "move" else self._copy(src) 

1267 

1268 # For resources of different classes or for plain HTTP resources we can 

1269 # perform the copy or move operation by downloading to a local file 

1270 # and uploading to the destination. 

1271 self._copy_via_local(src) 

1272 

1273 # This was an explicit move, try to remove the source. 

1274 if transfer == "move": 

1275 src.remove() 

1276 

1277 def walk( 

1278 self, file_filter: str | re.Pattern | None = None 

1279 ) -> Iterator[list | tuple[ResourcePath, list[str], list[str]]]: 

1280 """Walk the directory tree returning matching files and directories. 

1281 

1282 Parameters 

1283 ---------- 

1284 file_filter : `str` or `re.Pattern`, optional 

1285 Regex to filter out files from the list before it is returned. 

1286 

1287 Yields 

1288 ------ 

1289 dirpath : `ResourcePath` 

1290 Current directory being examined. 

1291 dirnames : `list` of `str` 

1292 Names of subdirectories within dirpath. 

1293 filenames : `list` of `str` 

1294 Names of all the files within dirpath. 

1295 """ 

1296 if not self.dirLike: 

1297 raise ValueError("Can not walk a non-directory URI") 

1298 

1299 # Walking directories is only available on WebDAV back ends. 

1300 if not self.is_webdav_endpoint: 

1301 raise NotImplementedError(f"Walking directory {self} is not implemented by plain HTTP servers") 

1302 

1303 if isinstance(file_filter, str): 

1304 file_filter = re.compile(file_filter) 

1305 

1306 resp = self._propfind(depth="1") 

1307 if resp.status_code == requests.codes.multi_status: # 207 

1308 files: list[str] = [] 

1309 dirs: list[str] = [] 

1310 

1311 for prop in _parse_propfind_response_body(resp.text): 

1312 if prop.is_file: 

1313 files.append(prop.name) 

1314 elif not prop.href.rstrip("/").endswith(self.path.rstrip("/")): 

1315 # Only include the names of sub-directories not the name of 

1316 # the directory being walked. 

1317 dirs.append(prop.name) 

1318 

1319 if file_filter is not None: 

1320 files = [f for f in files if file_filter.search(f)] 

1321 

1322 if not dirs and not files: 

1323 return 

1324 else: 

1325 yield type(self)(self, forceAbsolute=False, forceDirectory=True), dirs, files 

1326 

1327 for dir in dirs: 

1328 new_uri = self.join(dir, forceDirectory=True) 

1329 yield from new_uri.walk(file_filter) 

1330 

1331 def generate_presigned_get_url(self, *, expiration_time_seconds: int) -> str: 

1332 """Return a pre-signed URL that can be used to retrieve this resource 

1333 using an HTTP GET without supplying any access credentials. 

1334 

1335 Parameters 

1336 ---------- 

1337 expiration_time_seconds : `int` 

1338 Number of seconds until the generated URL is no longer valid. 

1339 

1340 Returns 

1341 ------- 

1342 url : `str` 

1343 HTTP URL signed for GET. 

1344 """ 

1345 if not self.is_webdav_endpoint: 

1346 # This is already an HTTP URL readable without any authentication 

1347 # credentials, so return it as-is. 

1348 return str(self) 

1349 

1350 return self._sign_with_macaroon(ActivityCaveat.DOWNLOAD, expiration_time_seconds) 

1351 

1352 def generate_presigned_put_url(self, *, expiration_time_seconds: int) -> str: 

1353 """Return a pre-signed URL that can be used to upload a file to this 

1354 path using an HTTP PUT without supplying any access credentials. 

1355 

1356 Parameters 

1357 ---------- 

1358 expiration_time_seconds : `int` 

1359 Number of seconds until the generated URL is no longer valid. 

1360 

1361 Returns 

1362 ------- 

1363 url : `str` 

1364 HTTP URL signed for PUT. 

1365 """ 

1366 if not self.is_webdav_endpoint: 

1367 return super().generate_presigned_put_url(expiration_time_seconds=expiration_time_seconds) 

1368 

1369 return self._sign_with_macaroon(ActivityCaveat.UPLOAD, expiration_time_seconds) 

1370 

1371 def to_fsspec(self) -> tuple[AbstractFileSystem, str]: 

1372 """Return an abstract file system and path that can be used by fsspec. 

1373 

1374 Returns 

1375 ------- 

1376 fs : `fsspec.spec.AbstractFileSystem` 

1377 A file system object suitable for use with the returned path. 

1378 path : `str` 

1379 A path that can be opened by the file system object. 

1380 """ 

1381 if fsspec is None: 

1382 return super().to_fsspec() 

1383 

1384 if not self.is_webdav_endpoint or self.server not in HttpResourcePath.SUPPORTED_URL_SIGNERS: 

1385 return fsspec.url_to_fs(self.geturl(), client_kwargs={"headers": self._extra_headers}) 

1386 

1387 if self.isdir(): 

1388 raise NotImplementedError( 

1389 f"method HttpResourcePath.to_fsspec() not implemented for directory {self}" 

1390 ) 

1391 

1392 # If usage of fsspec-compatible file system is disabled in the 

1393 # configuration we raise an exception which signals the caller 

1394 # that it cannot use fsspec. An example of such a caller is 

1395 # `lsst.daf.butler.formatters.ParquetFormatter`. 

1396 # 

1397 # Note that we don't call super().to_fsspec() since that method 

1398 # assumes that fsspec can be used provided fsspec package is 

1399 # importable. 

1400 # 

1401 # The motivation for making this configurable is that for HTTP 

1402 # URLs fsspec.HTTPFileSystem uses async I/O and we have found 

1403 # unexpected behavior by clients when used against dCache for reading 

1404 # parquet files via a ParquetFormatter instance. That behavior cannot 

1405 # be reproduced when using other callers. 

1406 # 

1407 # This needs more investigation to discard the possibility that async 

1408 # I/O, used by fsspec.HTTPFileSystem, is related to this behavior. 

1409 if not self._config.fsspec_is_enabled: 

1410 raise ImportError("fsspec is disabled for HttpResourcePath objects with webDAV back end") 

1411 

1412 async def get_client_session(**kwargs: Any) -> ClientSession: 

1413 """Return a aiohttp.ClientSession configured to use an 

1414 `aiohttp.TCPConnector` shared by all instances of this class. 

1415 

1416 Parameters 

1417 ---------- 

1418 **kwargs : `Any` 

1419 Keyword arguments passed unmodified to the contructor of 

1420 `aiohttp.ClientSession`. 

1421 

1422 Returns 

1423 ------- 

1424 session : `aiohttp.ClientSession` 

1425 Client session that `aiohttp.HTTPFileSystem` will use to pool 

1426 TCP connections to the server. 

1427 """ 

1428 if HttpResourcePath._tcp_connector is None: 

1429 HttpResourcePath._tcp_connector = TCPConnector( 

1430 # SSL context equipped with client credentials and 

1431 # configured to validate server certificates. 

1432 ssl=self._config.ssl_context, 

1433 # Total number of simultaneous connections this connector 

1434 # keeps open with any host. 

1435 # 

1436 # The default is 100 but we deliberately reduced it to 

1437 # avoid keeping a large number of open connexions to file 

1438 # servers when thousands of quanta execute simultaneously. 

1439 # 

1440 # In any case, new connexions are automatically established 

1441 # when needed. 

1442 limit=10, 

1443 # Number of simultaneous connections to a single host:port. 

1444 limit_per_host=1, 

1445 # Close network connection after usage 

1446 force_close=True, 

1447 ) 

1448 

1449 connect_timeout, read_timeout = self._config.timeout 

1450 return ClientSession( 

1451 connector=HttpResourcePath._tcp_connector, 

1452 timeout=ClientTimeout( 

1453 connect=connect_timeout, 

1454 sock_connect=connect_timeout, 

1455 sock_read=read_timeout, 

1456 total=2 * read_timeout, 

1457 ), 

1458 **kwargs, 

1459 ) 

1460 

1461 # Retrieve a signed URL for download valid for 2 hours. 

1462 url = self.generate_presigned_get_url(expiration_time_seconds=2 * 3_600) 

1463 

1464 # HTTPFileSystem constructor accepts the argument 'block_size'. The 

1465 # default value is 'fsspec.utils.DEFAULT_BLOCK_SIZE' which is 5 MB. 

1466 # That seems to be a reasonable block size for downloading files. 

1467 return HTTPFileSystem(get_client=get_client_session), url 

1468 

1469 def _sign_with_macaroon(self, activity: ActivityCaveat, expiration_time_seconds: int) -> str: 

1470 # dCache and XRootD webDAV servers support delivery of macaroons. 

1471 # 

1472 # For details about dCache macaroons see: 

1473 # https://www.dcache.org/manuals/UserGuide-9.2/macaroons.shtml 

1474 if self.server is None: 

1475 raise NotImplementedError(f"server for '{self}' does not support signing URLs") 

1476 elif self.server not in HttpResourcePath.SUPPORTED_URL_SIGNERS: 

1477 raise NotImplementedError(f"server '{self.server}' does not support signing for {self}") 

1478 

1479 match activity: 

1480 case ActivityCaveat.DOWNLOAD: 

1481 activity_caveat = "DOWNLOAD,LIST" 

1482 case ActivityCaveat.UPLOAD: 

1483 activity_caveat = "UPLOAD,LIST" 

1484 

1485 # Retrieve a macaroon for the requested activities and duration 

1486 headers = {"Content-Type": "application/macaroon-request"} 

1487 body = { 

1488 "caveats": [ 

1489 f"activity:{activity_caveat}", 

1490 ], 

1491 "validity": f"PT{expiration_time_seconds}S", 

1492 } 

1493 resp = self._post(data=json.dumps(body), headers=headers) 

1494 if resp.status_code != requests.codes.ok: 

1495 raise ValueError( 

1496 f"could not retrieve a macaroon for URL {self}, status: {resp.status_code} {resp.reason}" 

1497 ) 

1498 

1499 # We are expecting the body of the response to be formatted in JSON. 

1500 # dCache sets the 'Content-Type' of the response to 'application/json' 

1501 # but XRootD does not set any 'Content-Type' header 8-[ 

1502 # 

1503 # An example of a response body returned by dCache is shown below: 

1504 # { 

1505 # "macaroon": "MDA[...]Qo", 

1506 # "uri": { 

1507 # "targetWithMacaroon": "https://dcache.example.org/?authz=MD...", 

1508 # "baseWithMacaroon": "https://dcache.example.org/?authz=MD...", 

1509 # "target": "https://dcache.example.org/", 

1510 # "base": "https://dcache.example.org/" 

1511 # } 

1512 # } 

1513 # 

1514 # An example of a response body returned by XRootD is shown below: 

1515 # { 

1516 # "macaroon": "MDA[...]Qo", 

1517 # "expires_in": 86400 

1518 # } 

1519 try: 

1520 response_body = json.loads(resp.text) 

1521 if "macaroon" in response_body: 

1522 return str(self.replace(query=f"authz={response_body['macaroon']}")) 

1523 else: 

1524 raise ValueError(f"could not retrieve macaroon for URL {self}") 

1525 except json.JSONDecodeError: 

1526 raise ValueError(f"could not deserialize response to POST request for URL {self}") 

1527 

1528 @contextlib.contextmanager 

1529 def _as_local( 

1530 self, multithreaded: bool = True, tmpdir: ResourcePath | None = None 

1531 ) -> Iterator[ResourcePath]: 

1532 """Download object over HTTP and place in temporary directory. 

1533 

1534 Parameters 

1535 ---------- 

1536 multithreaded : `bool`, optional 

1537 If `True` the transfer will be allowed to attempt to improve 

1538 throughput by using parallel download streams. This may of no 

1539 effect if the URI scheme does not support parallel streams or 

1540 if a global override has been applied. If `False` parallel 

1541 streams will be disabled. 

1542 tmpdir : `ResourcePath` or `None`, optional 

1543 Explicit override of the temporary directory to use for remote 

1544 downloads. 

1545 

1546 Returns 

1547 ------- 

1548 local_uri : `ResourcePath` 

1549 A URI to a local POSIX file corresponding to a local temporary 

1550 downloaded copy of the resource. 

1551 """ 

1552 # Use the session as a context manager to ensure that connections 

1553 # to both the front end and back end servers are closed after the 

1554 # download operation is finished. 

1555 with self.data_session as session: 

1556 resp = session.get(self.geturl(), stream=True, timeout=self._config.timeout) 

1557 if resp.status_code != requests.codes.ok: 

1558 raise FileNotFoundError( 

1559 f"Unable to download resource {self}; status: {resp.status_code} {resp.reason}" 

1560 ) 

1561 

1562 if tmpdir is None: 

1563 temp_dir, buffer_size = self._config.tmpdir_buffersize 

1564 tmpdir = ResourcePath(temp_dir, forceDirectory=True) 

1565 else: 

1566 buffer_size = _calc_tmpdir_buffer_size(tmpdir.ospath) 

1567 

1568 with ResourcePath.temporary_uri( 

1569 suffix=self.getExtension(), prefix=tmpdir, delete=True 

1570 ) as tmp_uri: 

1571 expected_length = int(resp.headers.get("Content-Length", "-1")) 

1572 with time_this( 

1573 log, 

1574 msg="GET %s [length=%d] to local file %s [chunk_size=%d]", 

1575 args=(self, expected_length, tmp_uri, buffer_size), 

1576 mem_usage=self._config.collect_memory_usage, 

1577 mem_unit=u.mebibyte, 

1578 ): 

1579 content_length = 0 

1580 with open(tmp_uri.ospath, "wb", buffering=buffer_size) as tmpFile: 

1581 for chunk in resp.iter_content(chunk_size=buffer_size): 

1582 tmpFile.write(chunk) 

1583 content_length += len(chunk) 

1584 

1585 # Check that the expected and actual content lengths match. 

1586 # Perform this check only when the contents of the file was not 

1587 # encoded by the server. 

1588 if ( 

1589 "Content-Encoding" not in resp.headers 

1590 and expected_length >= 0 

1591 and expected_length != content_length 

1592 ): 

1593 raise ValueError( 

1594 f"Size of downloaded file does not match value in Content-Length header for {self}: " 

1595 f"expecting {expected_length} and got {content_length} bytes" 

1596 ) 

1597 

1598 yield tmp_uri 

1599 

1600 def _send_webdav_request( 

1601 self, 

1602 method: str, 

1603 url: str | None = None, 

1604 headers: dict[str, str] | None = None, 

1605 body: str | None = None, 

1606 session: _SessionWrapper | None = None, 

1607 timeout: tuple[float, float] | None = None, 

1608 ) -> requests.Response: 

1609 """Send a webDAV request and correctly handle redirects. 

1610 

1611 Parameters 

1612 ---------- 

1613 method : `str` 

1614 The mthod of the HTTP request to be sent, e.g. PROPFIND, MKCOL. 

1615 headers : `dict`, optional 

1616 A dictionary of key-value pairs (both strings) to include as 

1617 headers in the request. 

1618 body : `str`, optional 

1619 The body of the request. 

1620 

1621 Notes 

1622 ----- 

1623 This way of sending webDAV requests is necessary for handling 

1624 redirection ourselves, since the 'requests' package changes the method 

1625 of the redirected request when the server responds with status 302 and 

1626 the method of the original request is not HEAD (which is the case for 

1627 webDAV requests). 

1628 

1629 That means that when the webDAV server we interact with responds with 

1630 a redirection to a PROPFIND or MKCOL request, the request gets 

1631 converted to a GET request when sent to the redirected location. 

1632 

1633 See `requests.sessions.SessionRedirectMixin.rebuild_method()` in 

1634 https://github.com/psf/requests/blob/main/requests/sessions.py 

1635 

1636 This behavior of the 'requests' package is meant to be compatible with 

1637 what is specified in RFC 9110: 

1638 

1639 https://www.rfc-editor.org/rfc/rfc9110#name-302-found 

1640 

1641 For our purposes, we do need to follow the redirection and send a new 

1642 request using the same HTTP verb. 

1643 """ 

1644 if url is None: 

1645 url = self.geturl() 

1646 

1647 if headers is None: 

1648 headers = {} 

1649 

1650 if session is None: 

1651 session = self.metadata_session 

1652 

1653 if timeout is None: 

1654 timeout = self._config.timeout 

1655 

1656 with time_this( 

1657 log, 

1658 msg="%s %s", 

1659 args=( 

1660 method, 

1661 url, 

1662 ), 

1663 mem_usage=self._config.collect_memory_usage, 

1664 mem_unit=u.mebibyte, 

1665 ): 

1666 for _ in range(max_redirects := 5): 

1667 resp = session.request( 

1668 method, 

1669 url, 

1670 data=body, 

1671 headers=headers, 

1672 stream=False, 

1673 timeout=timeout, 

1674 allow_redirects=False, 

1675 ) 

1676 if resp.is_redirect: 

1677 url = resp.headers["Location"] 

1678 else: 

1679 return resp 

1680 

1681 # We reached the maximum allowed number of redirects. 

1682 # Stop trying. 

1683 raise ValueError( 

1684 f"Could not get a response to {method} request for {self} after {max_redirects} redirections" 

1685 ) 

1686 

1687 def _propfind(self, body: str | None = None, depth: str = "0") -> requests.Response: 

1688 """Send a PROPFIND webDAV request and return the response. 

1689 

1690 Parameters 

1691 ---------- 

1692 body : `str`, optional 

1693 The body of the PROPFIND request to send to the server. If 

1694 provided, it is expected to be a XML document. 

1695 depth : `str`, optional 

1696 The value of the 'Depth' header to include in the request. 

1697 

1698 Returns 

1699 ------- 

1700 response : `requests.Response` 

1701 Response to the PROPFIND request. 

1702 

1703 Notes 

1704 ----- 

1705 It raises `ValueError` if the status code of the PROPFIND request 

1706 is different from "207 Multistatus" or "404 Not Found". 

1707 """ 

1708 if body is None: 

1709 # Request only the DAV live properties we are explicitly interested 

1710 # in namely 'resourcetype', 'getcontentlength', 'getlastmodified' 

1711 # and 'displayname'. 

1712 body = ( 

1713 """<?xml version="1.0" encoding="utf-8" ?>""" 

1714 """<D:propfind xmlns:D="DAV:"><D:prop>""" 

1715 """<D:resourcetype/><D:getcontentlength/><D:getlastmodified/><D:displayname/>""" 

1716 """</D:prop></D:propfind>""" 

1717 ) 

1718 headers = { 

1719 "Depth": depth, 

1720 "Content-Type": 'application/xml; charset="utf-8"', 

1721 "Content-Length": str(len(body)), 

1722 } 

1723 resp = self._send_webdav_request("PROPFIND", headers=headers, body=body) 

1724 if resp.status_code in (requests.codes.multi_status, requests.codes.not_found): 

1725 return resp 

1726 else: 

1727 raise ValueError( 

1728 f"Unexpected response for PROPFIND request for {self}, status: {resp.status_code} " 

1729 f"{resp.reason}" 

1730 ) 

1731 

1732 def _options(self) -> requests.Response: 

1733 """Send a OPTIONS webDAV request for this resource.""" 

1734 resp = self._send_webdav_request("OPTIONS") 

1735 if resp.status_code in (requests.codes.ok, requests.codes.created): 

1736 return resp 

1737 

1738 raise ValueError( 

1739 f"Unexpected response to OPTIONS request for {self}, status: {resp.status_code} {resp.reason}" 

1740 ) 

1741 

1742 def _head(self) -> requests.Response: 

1743 """Send a HEAD request for this resource.""" 

1744 if not self.is_webdav_endpoint: 

1745 # The remote is a plain HTTP server. 

1746 return self._head_non_webdav_url() 

1747 return self._send_webdav_request("HEAD") 

1748 

1749 def _mkcol(self) -> None: 

1750 """Send a MKCOL webDAV request to create a collection. The collection 

1751 may already exist. 

1752 """ 

1753 resp = self._send_webdav_request("MKCOL") 

1754 if resp.status_code == requests.codes.created: # 201 

1755 return 

1756 

1757 if resp.status_code == requests.codes.method_not_allowed: # 405 

1758 # The remote directory already exists 

1759 log.debug("Can not create directory: %s may already exist: skipping.", self.geturl()) 

1760 else: 

1761 raise ValueError(f"Can not create directory {self}, status: {resp.status_code} {resp.reason}") 

1762 

1763 def _delete(self) -> None: 

1764 """Send a DELETE webDAV request for this resource.""" 

1765 log.debug("Deleting %s ...", self.geturl()) 

1766 

1767 # If this is a directory, ensure the remote is a webDAV server because 

1768 # plain HTTP servers don't support DELETE requests on non-file 

1769 # paths. 

1770 if self.dirLike and not self.is_webdav_endpoint: 

1771 raise NotImplementedError( 

1772 f"Deletion of directory {self} is not implemented by plain HTTP servers" 

1773 ) 

1774 

1775 # Deleting non-empty directories may take some time, so increase 

1776 # the timeout for getting a response from the server. 

1777 timeout = self._config.timeout 

1778 if self.dirLike: 

1779 timeout = (timeout[0], timeout[1] * 100) 

1780 resp = self._send_webdav_request("DELETE", timeout=timeout) 

1781 if resp.status_code in ( 

1782 requests.codes.ok, 

1783 requests.codes.accepted, 

1784 requests.codes.no_content, 

1785 requests.codes.not_found, 

1786 ): 

1787 # We can get a "404 Not Found" error when the file or directory 

1788 # does not exist or when the DELETE request was retried several 

1789 # times and a previous attempt actually deleted the resource. 

1790 # Therefore we consider that a "Not Found" response is not an 

1791 # error since we reached the state desired by the user. 

1792 return 

1793 else: 

1794 # TODO: the response to a DELETE request against a webDAV server 

1795 # may be multistatus. If so, we need to parse the reponse body to 

1796 # determine more precisely the reason of the failure (e.g. a lock) 

1797 # and provide a more helpful error message. 

1798 raise ValueError(f"Unable to delete resource {self}; status: {resp.status_code} {resp.reason}") 

1799 

1800 def _copy_via_local(self, src: ResourcePath) -> None: 

1801 """Replace the contents of this resource with the contents of a remote 

1802 resource by using a local temporary file. 

1803 

1804 Parameters 

1805 ---------- 

1806 src : `HttpResourcePath` 

1807 The source of the contents to copy to `self`. 

1808 """ 

1809 with src.as_local() as local_uri: 

1810 log.debug("Transfer from %s to %s via local file %s", src, self, local_uri) 

1811 with open(local_uri.ospath, "rb") as f: 

1812 self._put(data=f) 

1813 

1814 def _copy_or_move(self, method: str, src: HttpResourcePath) -> None: 

1815 """Send a COPY or MOVE webDAV request to copy or replace the contents 

1816 of this resource with the contents of another resource located in the 

1817 same server. 

1818 

1819 Parameters 

1820 ---------- 

1821 method : `str` 

1822 The method to perform. Valid values are "COPY" or "MOVE" (in 

1823 uppercase). 

1824 src : `HttpResourcePath` 

1825 The source of the contents to move to `self`. 

1826 """ 

1827 headers = {"Destination": self.geturl()} 

1828 resp = self._send_webdav_request(method, url=src.geturl(), headers=headers, session=self.data_session) 

1829 if resp.status_code in (requests.codes.created, requests.codes.no_content): 

1830 return 

1831 

1832 if resp.status_code == requests.codes.multi_status: 

1833 tree = eTree.fromstring(resp.content) 

1834 status_element = tree.find("./{DAV:}response/{DAV:}status") 

1835 status = status_element.text if status_element is not None else "unknown" 

1836 error = tree.find("./{DAV:}response/{DAV:}error") 

1837 raise ValueError(f"{method} returned multistatus reponse with status {status} and error {error}") 

1838 else: 

1839 raise ValueError( 

1840 f"{method} operation from {src} to {self} failed, status: {resp.status_code} {resp.reason}" 

1841 ) 

1842 

1843 def _copy(self, src: HttpResourcePath) -> None: 

1844 """Send a COPY webDAV request to replace the contents of this resource 

1845 (if any) with the contents of another resource located in the same 

1846 server. 

1847 

1848 Parameters 

1849 ---------- 

1850 src : `HttpResourcePath` 

1851 The source of the contents to copy to `self`. 

1852 """ 

1853 # Neither dCache nor XrootD currently implement the COPY 

1854 # webDAV method as documented in 

1855 # http://www.webdav.org/specs/rfc4918.html#METHOD_COPY 

1856 # (See issues DM-37603 and DM-37651 for details) 

1857 # 

1858 # For the time being, we use a temporary local file to 

1859 # perform the copy client side. 

1860 # TODO: when those 2 issues above are solved remove the 3 lines below. 

1861 must_use_local = True 

1862 if must_use_local: 

1863 return self._copy_via_local(src) 

1864 

1865 return self._copy_or_move("COPY", src) 

1866 

1867 def _move(self, src: HttpResourcePath) -> None: 

1868 """Send a MOVE webDAV request to replace the contents of this resource 

1869 with the contents of another resource located in the same server. 

1870 

1871 Parameters 

1872 ---------- 

1873 src : `HttpResourcePath` 

1874 The source of the contents to move to `self`. 

1875 """ 

1876 return self._copy_or_move("MOVE", src) 

1877 

1878 def _post(self, data: str | None = None, headers: dict[str, str] | None = None) -> requests.Response: 

1879 """Perform an HTTP POST request and returns the received response. 

1880 

1881 Parameters 

1882 ---------- 

1883 body : `bytes` 

1884 The contents of the request body. 

1885 """ 

1886 resp = self.metadata_session.request( 

1887 "POST", 

1888 self.geturl(), 

1889 data=data, 

1890 headers=headers, 

1891 stream=False, 

1892 timeout=self._config.timeout, 

1893 allow_redirects=True, 

1894 ) 

1895 if resp.status_code == requests.codes.ok: 

1896 return resp 

1897 

1898 raise ValueError(f"POST request for {self} failed, status: {resp.status_code} {resp.reason}") 

1899 

1900 def _put(self, data: BinaryIO | bytes) -> None: 

1901 """Perform an HTTP PUT request and handle redirection. 

1902 

1903 Parameters 

1904 ---------- 

1905 data : `Union[BinaryIO, bytes]` 

1906 The data to be included in the body of the PUT request. 

1907 """ 

1908 # Retrieve the final URL for this upload by sending a PUT request with 

1909 # no content. Follow a single server redirection to retrieve the 

1910 # final URL. 

1911 headers = {"Content-Length": "0"} 

1912 

1913 # If we are explicitly configured for or if we know the remote server 

1914 # is dCache, send an "Expect" header to signal the server that this 

1915 # client knows how to handle redirection in PUT requests. 

1916 # 

1917 # The goal is that the contents of the file we want to upload is sent 

1918 # directly to the dCache pool without transiting through the dCache 

1919 # webDAV door. Otherwise, the uploaded data would transit twice over 

1920 # the network: first from this client to the dCache webDAV door and 

1921 # second from webDAV door to the target pool (i.e. the dCache file 

1922 # server which will ultimately store the data we will upload). 

1923 # 

1924 # Systematically uploading data via dCache webDAV door could add 

1925 # unnecessary load to the door which we can avoid by instead uploading 

1926 # directly to the dCache pool. 

1927 # 

1928 # For further details see section "Redirection on upload": 

1929 # 

1930 # https://www.dcache.org/manuals/UserGuide-9.2/webdav.shtml#redirection 

1931 if self._config.send_expect_on_put or self.server == "dcache": 

1932 headers["Expect"] = "100-continue" 

1933 

1934 url = self.geturl() 

1935 

1936 # Use the session as a context manager to ensure the underlying 

1937 # connections are closed after finishing uploading the data. 

1938 with self.data_session as session: 

1939 # Send an empty PUT request to get redirected to the final 

1940 # destination. 

1941 log.debug("Sending empty PUT request to %s", url) 

1942 with time_this( 

1943 log, 

1944 msg="PUT (no data) %s", 

1945 args=(url,), 

1946 mem_usage=self._config.collect_memory_usage, 

1947 mem_unit=u.mebibyte, 

1948 ): 

1949 resp = session.request( 

1950 "PUT", 

1951 url, 

1952 data=None, 

1953 headers=headers, 

1954 stream=False, 

1955 timeout=self._config.timeout, 

1956 allow_redirects=False, 

1957 ) 

1958 if resp.is_redirect: 

1959 url = resp.headers["Location"] 

1960 

1961 # Upload the data to the final destination. 

1962 log.debug("Uploading data to %s", url) 

1963 

1964 # Ask the server to compute and record a checksum of the uploaded 

1965 # file contents, for later integrity checks. Since we don't compute 

1966 # the digest ourselves while uploading the data, we cannot control 

1967 # after the request is complete that the data we uploaded is 

1968 # identical to the data recorded by the server, but at least the 

1969 # server has recorded a digest of the data it stored. 

1970 # 

1971 # See RFC-3230 for details and 

1972 # https://www.iana.org/assignments/http-dig-alg/http-dig-alg.xhtml 

1973 # for the list of supported digest algorithhms. 

1974 # In addition, note that not all servers implement this RFC so 

1975 # the checksum may not be computed by the server. 

1976 put_headers: dict[str, str] | None = None 

1977 if digest := self._config.digest_algorithm: 

1978 put_headers = {"Want-Digest": digest} 

1979 

1980 with time_this( 

1981 log, 

1982 msg="PUT %s", 

1983 args=(url,), 

1984 mem_usage=self._config.collect_memory_usage, 

1985 mem_unit=u.mebibyte, 

1986 ): 

1987 resp = session.request( 

1988 "PUT", 

1989 url, 

1990 data=data, 

1991 headers=put_headers, 

1992 stream=False, 

1993 timeout=self._config.timeout, 

1994 allow_redirects=False, 

1995 ) 

1996 if resp.status_code in ( 

1997 requests.codes.ok, 

1998 requests.codes.created, 

1999 requests.codes.no_content, 

2000 ): 

2001 return 

2002 else: 

2003 raise ValueError(f"Can not write file {self}, status: {resp.status_code} {resp.reason}") 

2004 

2005 @contextlib.contextmanager 

2006 def _openImpl( 

2007 self, 

2008 mode: str = "r", 

2009 *, 

2010 encoding: str | None = None, 

2011 ) -> Iterator[ResourceHandleProtocol]: 

2012 resp = self._head() 

2013 accepts_range = resp.status_code == requests.codes.ok and resp.headers.get("Accept-Ranges") == "bytes" 

2014 handle: ResourceHandleProtocol 

2015 if mode in ("rb", "r") and accepts_range: 

2016 handle = HttpReadResourceHandle(mode, log, self, timeout=self._config.timeout) 

2017 if mode == "r": 

2018 # cast because the protocol is compatible, but does not have 

2019 # BytesIO in the inheritance tree 

2020 yield io.TextIOWrapper(cast(Any, handle), encoding=encoding) 

2021 else: 

2022 yield handle 

2023 else: 

2024 with super()._openImpl(mode, encoding=encoding) as http_handle: 

2025 yield http_handle 

2026 

2027 def _copy_extra_attributes(self, original_uri: ResourcePath) -> None: 

2028 assert isinstance(original_uri, HttpResourcePath) 

2029 self._extra_headers = original_uri._extra_headers 

2030 

2031 

2032def _dump_response(resp: requests.Response) -> None: 

2033 """Log the contents of a HTTP or webDAV request and its response. 

2034 

2035 Parameters 

2036 ---------- 

2037 resp : `requests.Response` 

2038 The response to log. 

2039 

2040 Notes 

2041 ----- 

2042 Intended for development purposes only. 

2043 """ 

2044 log.debug("-----------------------------------------------") 

2045 log.debug("Request") 

2046 log.debug(" method=%s", resp.request.method) 

2047 log.debug(" URL=%s", resp.request.url) 

2048 log.debug(" headers=%s", resp.request.headers) 

2049 if resp.request.method == "PUT": 

2050 log.debug(" body=<data>") 

2051 elif resp.request.body is None: 

2052 log.debug(" body=<empty>") 

2053 else: 

2054 log.debug(" body=%r", resp.request.body[:120]) 

2055 

2056 log.debug("Response:") 

2057 log.debug(" status_code=%d", resp.status_code) 

2058 log.debug(" headers=%s", resp.headers) 

2059 if not resp.content: 

2060 log.debug(" body=<empty>") 

2061 elif "Content-Type" in resp.headers and resp.headers["Content-Type"] == "text/plain": 

2062 log.debug(" body=%r", resp.content) 

2063 else: 

2064 log.debug(" body=%r", resp.content[:80]) 

2065 

2066 

2067def _is_protected(filepath: str) -> bool: 

2068 """Return true if the permissions of file at filepath only allow for access 

2069 by its owner. 

2070 

2071 Parameters 

2072 ---------- 

2073 filepath : `str` 

2074 Path of a local file. 

2075 """ 

2076 if not os.path.isfile(filepath): 

2077 return False 

2078 mode = stat.S_IMODE(os.stat(filepath).st_mode) 

2079 owner_accessible = bool(mode & stat.S_IRWXU) 

2080 group_accessible = bool(mode & stat.S_IRWXG) 

2081 other_accessible = bool(mode & stat.S_IRWXO) 

2082 return owner_accessible and not group_accessible and not other_accessible 

2083 

2084 

2085def _parse_propfind_response_body(body: str) -> list[DavProperty]: 

2086 """Parse the XML-encoded contents of the response body to a webDAV PROPFIND 

2087 request. 

2088 

2089 Parameters 

2090 ---------- 

2091 body : `str` 

2092 XML-encoded response body to a PROPFIND request 

2093 

2094 Returns 

2095 ------- 

2096 responses : `List[DavProperty]` 

2097 

2098 Notes 

2099 ----- 

2100 Is is expected that there is at least one reponse in `body`, otherwise 

2101 this function raises. 

2102 """ 

2103 # A response body to a PROPFIND request is of the form (indented for 

2104 # readability): 

2105 # 

2106 # <?xml version="1.0" encoding="UTF-8"?> 

2107 # <D:multistatus xmlns:D="DAV:"> 

2108 # <D:response> 

2109 # <D:href>path/to/resource</D:href> 

2110 # <D:propstat> 

2111 # <D:prop> 

2112 # <D:resourcetype> 

2113 # <D:collection xmlns:D="DAV:"/> 

2114 # </D:resourcetype> 

2115 # <D:getlastmodified> 

2116 # Fri, 27 Jan 2 023 13:59:01 GMT 

2117 # </D:getlastmodified> 

2118 # <D:getcontentlength> 

2119 # 12345 

2120 # </D:getcontentlength> 

2121 # </D:prop> 

2122 # <D:status> 

2123 # HTTP/1.1 200 OK 

2124 # </D:status> 

2125 # </D:propstat> 

2126 # </D:response> 

2127 # <D:response> 

2128 # ... 

2129 # </D:response> 

2130 # <D:response> 

2131 # ... 

2132 # </D:response> 

2133 # </D:multistatus> 

2134 

2135 # Scan all the 'response' elements and extract the relevant properties 

2136 responses = [] 

2137 multistatus = eTree.fromstring(body.strip()) 

2138 for response in multistatus.findall("./{DAV:}response"): 

2139 responses.append(DavProperty(response)) 

2140 

2141 if responses: 

2142 return responses 

2143 else: 

2144 # Could not parse the body 

2145 raise ValueError(f"Unable to parse response for PROPFIND request: {body}") 

2146 

2147 

2148class DavProperty: 

2149 """Helper class to encapsulate select live DAV properties of a single 

2150 resource, as retrieved via a PROPFIND request. 

2151 

2152 Parameters 

2153 ---------- 

2154 response : `eTree.Element` or `None` 

2155 The XML response defining the DAV property. 

2156 """ 

2157 

2158 # Regular expression to compare against the 'status' element of a 

2159 # PROPFIND response's 'propstat' element. 

2160 _status_ok_rex = re.compile(r"^HTTP/.* 200 .*$", re.IGNORECASE) 

2161 

2162 def __init__(self, response: eTree.Element | None): 

2163 self._href: str = "" 

2164 self._displayname: str = "" 

2165 self._collection: bool = False 

2166 self._getlastmodified: str = "" 

2167 self._getcontentlength: int = -1 

2168 

2169 if response is not None: 

2170 self._parse(response) 

2171 

2172 def _parse(self, response: eTree.Element) -> None: 

2173 # Extract 'href'. 

2174 if (element := response.find("./{DAV:}href")) is not None: 

2175 # We need to use "str(element.text)"" instead of "element.text" to 

2176 # keep mypy happy. 

2177 self._href = str(element.text).strip() 

2178 else: 

2179 raise ValueError( 

2180 "Property 'href' expected but not found in PROPFIND response: " 

2181 f"{eTree.tostring(response, encoding='unicode')}" 

2182 ) 

2183 

2184 for propstat in response.findall("./{DAV:}propstat"): 

2185 # Only extract properties of interest with status OK. 

2186 status = propstat.find("./{DAV:}status") 

2187 if status is None or not self._status_ok_rex.match(str(status.text)): 

2188 continue 

2189 

2190 for prop in propstat.findall("./{DAV:}prop"): 

2191 # Parse "collection". 

2192 if (element := prop.find("./{DAV:}resourcetype/{DAV:}collection")) is not None: 

2193 self._collection = True 

2194 

2195 # Parse "getlastmodified". 

2196 if (element := prop.find("./{DAV:}getlastmodified")) is not None: 

2197 self._getlastmodified = str(element.text) 

2198 

2199 # Parse "getcontentlength". 

2200 if (element := prop.find("./{DAV:}getcontentlength")) is not None: 

2201 self._getcontentlength = int(str(element.text)) 

2202 

2203 # Parse "displayname". 

2204 if (element := prop.find("./{DAV:}displayname")) is not None: 

2205 self._displayname = str(element.text) 

2206 

2207 # Some webDAV servers don't include the 'displayname' property in the 

2208 # response so try to infer it from the value of the 'href' property. 

2209 # Depending on the server the href value may end with '/'. 

2210 if not self._displayname: 

2211 self._displayname = os.path.basename(self._href.rstrip("/")) 

2212 

2213 # Force a size of 0 for collections. 

2214 if self._collection: 

2215 self._getcontentlength = 0 

2216 

2217 @property 

2218 def exists(self) -> bool: 

2219 # It is either a directory or a file with length of at least zero 

2220 return self._collection or self._getcontentlength >= 0 

2221 

2222 @property 

2223 def is_directory(self) -> bool: 

2224 return self._collection 

2225 

2226 @property 

2227 def is_file(self) -> bool: 

2228 return not self._collection 

2229 

2230 @property 

2231 def size(self) -> int: 

2232 return self._getcontentlength 

2233 

2234 @property 

2235 def name(self) -> str: 

2236 return self._displayname 

2237 

2238 @property 

2239 def href(self) -> str: 

2240 return self._href 

2241 

2242 

2243class _SessionWrapper(contextlib.AbstractContextManager): 

2244 """Wraps a `requests.Session` to allow header values to be injected with 

2245 all requests. 

2246 

2247 Notes 

2248 ----- 

2249 `requests.Session` already has a feature for setting headers globally, but 

2250 our session objects are global and authorization headers can vary for each 

2251 HttpResourcePath instance. 

2252 """ 

2253 

2254 def __init__(self, session: requests.Session, *, extra_headers: dict[str, str] | None) -> None: 

2255 self._session = session 

2256 self._extra_headers = extra_headers 

2257 

2258 def __enter__(self) -> _SessionWrapper: 

2259 self._session.__enter__() 

2260 return self 

2261 

2262 def __exit__( 

2263 self, 

2264 exc_type: Any, 

2265 exc_value: Any, 

2266 traceback: Any, 

2267 ) -> None: 

2268 return self._session.__exit__(exc_type, exc_value, traceback) 

2269 

2270 def get( 

2271 self, 

2272 url: str, 

2273 *, 

2274 timeout: tuple[float, float], 

2275 allow_redirects: bool = True, 

2276 stream: bool, 

2277 headers: dict[str, str] | None = None, 

2278 ) -> requests.Response: 

2279 return self._session.get( 

2280 url, 

2281 timeout=timeout, 

2282 allow_redirects=allow_redirects, 

2283 stream=stream, 

2284 headers=self._augment_headers(headers), 

2285 ) 

2286 

2287 def head( 

2288 self, 

2289 url: str, 

2290 *, 

2291 timeout: tuple[float, float], 

2292 allow_redirects: bool, 

2293 stream: bool, 

2294 headers: dict[str, str] | None = None, 

2295 ) -> requests.Response: 

2296 return self._session.head( 

2297 url, 

2298 timeout=timeout, 

2299 allow_redirects=allow_redirects, 

2300 stream=stream, 

2301 headers=self._augment_headers(headers), 

2302 ) 

2303 

2304 def request( 

2305 self, 

2306 method: str, 

2307 url: str, 

2308 *, 

2309 data: str | bytes | BinaryIO | None, 

2310 timeout: tuple[float, float], 

2311 allow_redirects: bool, 

2312 stream: bool, 

2313 headers: dict[str, str] | None = None, 

2314 ) -> requests.Response: 

2315 return self._session.request( 

2316 method, 

2317 url, 

2318 data=data, 

2319 timeout=timeout, 

2320 allow_redirects=allow_redirects, 

2321 stream=stream, 

2322 headers=self._augment_headers(headers), 

2323 ) 

2324 

2325 def _augment_headers(self, headers: dict[str, str] | None) -> dict[str, str]: 

2326 if headers is None: 

2327 headers = {} 

2328 

2329 if self._extra_headers is not None: 

2330 headers = headers | self._extra_headers 

2331 

2332 return headers