Coverage for python/lsst/resources/_resourcePath.py: 21%

554 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-25 00:30 -0800

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__ = ("ResourcePath", "ResourcePathExpression") 

15 

16import concurrent.futures 

17import contextlib 

18import copy 

19import io 

20import locale 

21import logging 

22import os 

23import posixpath 

24import re 

25import sys 

26import urllib.parse 

27from collections import defaultdict 

28from pathlib import Path, PurePath, PurePosixPath 

29from random import Random 

30from typing import TypeAlias 

31 

32try: 

33 import fsspec 

34 from fsspec.spec import AbstractFileSystem 

35except ImportError: 

36 fsspec = None 

37 AbstractFileSystem = type 

38 

39from collections.abc import Iterable, Iterator 

40from typing import TYPE_CHECKING, Any, Literal, NamedTuple, overload 

41 

42from ._resourceHandles._baseResourceHandle import ResourceHandleProtocol 

43from .utils import _get_num_workers, get_tempdir 

44 

45if TYPE_CHECKING: 

46 from .utils import TransactionProtocol 

47 

48 

49log = logging.getLogger(__name__) 

50 

51# Regex for looking for URI escapes 

52ESCAPES_RE = re.compile(r"%[A-F0-9]{2}") 

53 

54# Precomputed escaped hash 

55ESCAPED_HASH = urllib.parse.quote("#") 

56 

57 

58class MBulkResult(NamedTuple): 

59 """Report on a bulk operation.""" 

60 

61 success: bool 

62 exception: Exception | None 

63 

64 

65_EXECUTOR_TYPE: TypeAlias = type[ 

66 concurrent.futures.ThreadPoolExecutor | concurrent.futures.ProcessPoolExecutor 

67] 

68 

69# Cache value for executor class so as not to issue warning multiple 

70# times but still allow tests to override the value. 

71_POOL_EXECUTOR_CLASS: _EXECUTOR_TYPE | None = None 

72 

73 

74def _get_executor_class() -> _EXECUTOR_TYPE: 

75 """Return the executor class used for parallelized execution. 

76 

77 Returns 

78 ------- 

79 cls : `concurrent.futures.Executor` 

80 The ``Executor`` class. Default is 

81 `concurrent.futures.ThreadPoolExecutor`. Can be set explicitly by 

82 setting the ``$LSST_RESOURCES_EXECUTOR`` environment variable to 

83 "thread" or "process". Returns "thread" pool if the value of the 

84 variable is not recognized. 

85 """ 

86 global _POOL_EXECUTOR_CLASS 

87 

88 if _POOL_EXECUTOR_CLASS is not None: 

89 return _POOL_EXECUTOR_CLASS 

90 

91 pool_executor_classes = { 

92 "threads": concurrent.futures.ThreadPoolExecutor, 

93 "process": concurrent.futures.ProcessPoolExecutor, 

94 } 

95 default_executor = "threads" 

96 external = os.getenv("LSST_RESOURCES_EXECUTOR", default_executor) 

97 if not external: 

98 external = default_executor 

99 if external not in pool_executor_classes: 

100 log.warning( 

101 "Unrecognized value of '%s' for LSST_RESOURCES_EXECUTOR env var. Using '%s'", 

102 external, 

103 default_executor, 

104 ) 

105 external = default_executor 

106 _POOL_EXECUTOR_CLASS = pool_executor_classes[external] 

107 return _POOL_EXECUTOR_CLASS 

108 

109 

110@contextlib.contextmanager 

111def _patch_environ(new_values: dict[str, str]) -> Iterator[None]: 

112 """Patch os.environ temporarily using the supplied values. 

113 

114 Parameters 

115 ---------- 

116 new_values : `dict` [ `str`, `str` ] 

117 New values to be stored in the environment. 

118 """ 

119 old_values: dict[str, str] = {} 

120 for k, v in new_values.items(): 

121 if k in os.environ: 

122 old_values[k] = os.environ[k] 

123 os.environ[k] = v 

124 

125 try: 

126 yield 

127 finally: 

128 for k in new_values: 

129 del os.environ[k] 

130 if k in old_values: 

131 os.environ[k] = old_values[k] 

132 

133 

134class ResourcePath: # numpydoc ignore=PR02 

135 """Convenience wrapper around URI parsers. 

136 

137 Provides access to URI components and can convert file 

138 paths into absolute path URIs. Scheme-less URIs are treated as if 

139 they are local file system paths and are converted to absolute URIs. 

140 

141 A specialist subclass is created for each supported URI scheme. 

142 

143 Parameters 

144 ---------- 

145 uri : `str`, `pathlib.Path`, `urllib.parse.ParseResult`, or `ResourcePath` 

146 URI in string form. Can be scheme-less if referring to a relative 

147 path or an absolute path on the local file system. 

148 root : `str` or `ResourcePath`, optional 

149 When fixing up a relative path in a ``file`` scheme or if scheme-less, 

150 use this as the root. Must be absolute. If `None` the current 

151 working directory will be used. Can be any supported URI scheme. 

152 Not used if ``forceAbsolute`` is `False`. 

153 forceAbsolute : `bool`, optional 

154 If `True`, scheme-less relative URI will be converted to an absolute 

155 path using a ``file`` scheme. If `False` scheme-less URI will remain 

156 scheme-less and will not be updated to ``file`` or absolute path unless 

157 it is already an absolute path, in which case it will be updated to 

158 a ``file`` scheme. 

159 forceDirectory : `bool` or `None`, optional 

160 If `True` forces the URI to end with a separator. If `False` the URI 

161 is interpreted as a file-like entity. Default, `None`, is that the 

162 given URI is interpreted as a directory if there is a trailing ``/`` or 

163 for some schemes the system will check to see if it is a file or a 

164 directory. 

165 isTemporary : `bool`, optional 

166 If `True` indicates that this URI points to a temporary resource. 

167 The default is `False`, unless ``uri`` is already a `ResourcePath` 

168 instance and ``uri.isTemporary is True``. 

169 

170 Notes 

171 ----- 

172 A non-standard URI of the form ``file:dir/file.txt`` is always converted 

173 to an absolute ``file`` URI. 

174 """ 

175 

176 _pathLib: type[PurePath] = PurePosixPath 

177 """Path library to use for this scheme.""" 

178 

179 _pathModule = posixpath 

180 """Path module to use for this scheme.""" 

181 

182 transferModes: tuple[str, ...] = ("copy", "auto", "move") 

183 """Transfer modes supported by this implementation. 

184 

185 Move is special in that it is generally a copy followed by an unlink. 

186 Whether that unlink works depends critically on whether the source URI 

187 implements unlink. If it does not the move will be reported as a failure. 

188 """ 

189 

190 transferDefault: str = "copy" 

191 """Default mode to use for transferring if ``auto`` is specified.""" 

192 

193 quotePaths = True 

194 """True if path-like elements modifying a URI should be quoted. 

195 

196 All non-schemeless URIs have to internally use quoted paths. Therefore 

197 if a new file name is given (e.g. to updatedFile or join) a decision must 

198 be made whether to quote it to be consistent. 

199 """ 

200 

201 isLocal = False 

202 """If `True` this URI refers to a local file.""" 

203 

204 # This is not an ABC with abstract methods because the __new__ being 

205 # a factory confuses mypy such that it assumes that every constructor 

206 # returns a ResourcePath and then determines that all the abstract methods 

207 # are still abstract. If they are not marked abstract but just raise 

208 # mypy is fine with it. 

209 

210 # mypy is confused without these 

211 _uri: urllib.parse.ParseResult 

212 isTemporary: bool 

213 dirLike: bool | None 

214 """Whether the resource looks like a directory resource. `None` means that 

215 the status is uncertain.""" 

216 

217 def __new__( 

218 cls, 

219 uri: ResourcePathExpression, 

220 root: str | ResourcePath | None = None, 

221 forceAbsolute: bool = True, 

222 forceDirectory: bool | None = None, 

223 isTemporary: bool | None = None, 

224 ) -> ResourcePath: 

225 """Create and return new specialist ResourcePath subclass.""" 

226 parsed: urllib.parse.ParseResult 

227 dirLike: bool | None = forceDirectory 

228 subclass: type[ResourcePath] | None = None 

229 

230 # Force root to be a ResourcePath -- this simplifies downstream 

231 # code. 

232 if root is None: 

233 root_uri = None 

234 elif isinstance(root, str): 

235 root_uri = ResourcePath(root, forceDirectory=True, forceAbsolute=True) 

236 else: 

237 root_uri = root 

238 

239 if isinstance(uri, os.PathLike): 

240 uri = str(uri) 

241 

242 # Record if we need to post process the URI components 

243 # or if the instance is already fully configured 

244 if isinstance(uri, str): 

245 # Since local file names can have special characters in them 

246 # we need to quote them for the parser but we can unquote 

247 # later. Assume that all other URI schemes are quoted. 

248 # Since sometimes people write file:/a/b and not file:///a/b 

249 # we should not quote in the explicit case of file: 

250 if "://" not in uri and not uri.startswith("file:"): 

251 if ESCAPES_RE.search(uri): 

252 log.warning("Possible double encoding of %s", uri) 

253 else: 

254 # Fragments are generally not encoded so we must search 

255 # for the fragment boundary ourselves. This is making 

256 # an assumption that the filename does not include a "#" 

257 # and also that there is no "/" in the fragment itself. 

258 to_encode = uri 

259 fragment = "" 

260 if "#" in uri: 

261 dirpos = uri.rfind("/") 

262 trailing = uri[dirpos + 1 :] 

263 hashpos = trailing.rfind("#") 

264 if hashpos != -1: 

265 fragment = trailing[hashpos:] 

266 to_encode = uri[: dirpos + hashpos + 1] 

267 

268 uri = urllib.parse.quote(to_encode) + fragment 

269 

270 parsed = urllib.parse.urlparse(uri) 

271 elif isinstance(uri, urllib.parse.ParseResult): 

272 parsed = copy.copy(uri) 

273 # If we are being instantiated with a subclass, rather than 

274 # ResourcePath, ensure that that subclass is used directly. 

275 # This could lead to inconsistencies if this constructor 

276 # is used externally outside of the ResourcePath.replace() method. 

277 # S3ResourcePath(urllib.parse.urlparse("file://a/b.txt")) 

278 # will be a problem. 

279 # This is needed to prevent a schemeless absolute URI become 

280 # a file URI unexpectedly when calling updatedFile or 

281 # updatedExtension 

282 if cls is not ResourcePath: 

283 parsed, dirLike = cls._fixDirectorySep(parsed, forceDirectory) 

284 subclass = cls 

285 

286 elif isinstance(uri, ResourcePath): 

287 # Since ResourcePath is immutable we can return the argument 

288 # unchanged if it already agrees with forceDirectory, isTemporary, 

289 # and forceAbsolute. 

290 # We invoke __new__ again with str(self) to add a scheme for 

291 # forceAbsolute, but for the others that seems more likely to paper 

292 # over logic errors than do something useful, so we just raise. 

293 if forceDirectory is not None and uri.dirLike is not None and forceDirectory is not uri.dirLike: 

294 # Can not force a file-like URI to become a dir-like one or 

295 # vice versa. 

296 raise RuntimeError( 

297 f"{uri} can not be forced to change directory vs file state when previously declared." 

298 ) 

299 if isTemporary is not None and isTemporary is not uri.isTemporary: 

300 raise RuntimeError( 

301 f"{uri} is already a {'temporary' if uri.isTemporary else 'permanent'} " 

302 f"ResourcePath; cannot make it {'temporary' if isTemporary else 'permanent'}." 

303 ) 

304 

305 if forceAbsolute and not uri.scheme: 

306 # Create new absolute from relative. 

307 return ResourcePath( 

308 str(uri), 

309 root=root, 

310 forceAbsolute=forceAbsolute, 

311 forceDirectory=forceDirectory or uri.dirLike, 

312 isTemporary=uri.isTemporary, 

313 ) 

314 elif forceDirectory is not None and uri.dirLike is None: 

315 # Clone but with a new dirLike status. 

316 return uri.replace(forceDirectory=forceDirectory) 

317 return uri 

318 else: 

319 raise ValueError( 

320 f"Supplied URI must be string, Path, ResourcePath, or ParseResult but got '{uri!r}'" 

321 ) 

322 

323 if subclass is None: 

324 # Work out the subclass from the URI scheme 

325 if not parsed.scheme: 

326 # Root may be specified as a ResourcePath that overrides 

327 # the schemeless determination. 

328 if ( 

329 root_uri is not None 

330 and root_uri.scheme != "file" # file scheme has different code path 

331 and not parsed.path.startswith("/") # Not already absolute path 

332 ): 

333 if root_uri.dirLike is False: 

334 raise ValueError( 

335 f"Root URI ({root}) was not a directory so can not be joined with" 

336 f" path {parsed.path!r}" 

337 ) 

338 # If root is temporary or this schemeless is temporary we 

339 # assume this URI is temporary. 

340 isTemporary = isTemporary or root_uri.isTemporary 

341 joined = root_uri.join( 

342 parsed.path, forceDirectory=forceDirectory, isTemporary=isTemporary 

343 ) 

344 

345 # Rather than returning this new ResourcePath directly we 

346 # instead extract the path and the scheme and adjust the 

347 # URI we were given -- we need to do this to preserve 

348 # fragments since join() will drop them. 

349 parsed = parsed._replace(scheme=joined.scheme, path=joined.path, netloc=joined.netloc) 

350 subclass = type(joined) 

351 

352 # Clear the root parameter to indicate that it has 

353 # been applied already. 

354 root_uri = None 

355 else: 

356 from .schemeless import SchemelessResourcePath 

357 

358 subclass = SchemelessResourcePath 

359 elif parsed.scheme == "file": 

360 from .file import FileResourcePath 

361 

362 subclass = FileResourcePath 

363 elif parsed.scheme == "s3": 

364 from .s3 import S3ResourcePath 

365 

366 subclass = S3ResourcePath 

367 elif parsed.scheme.startswith("http"): 

368 from .http import HttpResourcePath 

369 

370 subclass = HttpResourcePath 

371 elif parsed.scheme in {"dav", "davs"}: 

372 from .dav import DavResourcePath 

373 

374 subclass = DavResourcePath 

375 elif parsed.scheme == "gs": 

376 from .gs import GSResourcePath 

377 

378 subclass = GSResourcePath 

379 elif parsed.scheme == "resource": 

380 # Rules for scheme names disallow pkg_resource 

381 from .packageresource import PackageResourcePath 

382 

383 subclass = PackageResourcePath 

384 elif parsed.scheme == "mem": 

385 # in-memory datastore object 

386 from .mem import InMemoryResourcePath 

387 

388 subclass = InMemoryResourcePath 

389 else: 

390 raise NotImplementedError( 

391 f"No URI support for scheme: '{parsed.scheme}' in {parsed.geturl()}" 

392 ) 

393 

394 parsed, dirLike = subclass._fixupPathUri( 

395 parsed, root=root_uri, forceAbsolute=forceAbsolute, forceDirectory=forceDirectory 

396 ) 

397 

398 # It is possible for the class to change from schemeless 

399 # to file so handle that 

400 if parsed.scheme == "file": 

401 from .file import FileResourcePath 

402 

403 subclass = FileResourcePath 

404 

405 # Now create an instance of the correct subclass and set the 

406 # attributes directly 

407 self = object.__new__(subclass) 

408 self._uri = parsed 

409 self.dirLike = dirLike 

410 if isTemporary is None: 

411 isTemporary = False 

412 self.isTemporary = isTemporary 

413 return self 

414 

415 @property 

416 def scheme(self) -> str: 

417 """Return the URI scheme. 

418 

419 Notes 

420 ----- 

421 (``://`` is not part of the scheme). 

422 """ 

423 return self._uri.scheme 

424 

425 @property 

426 def netloc(self) -> str: 

427 """Return the URI network location.""" 

428 return self._uri.netloc 

429 

430 @property 

431 def path(self) -> str: 

432 """Return the path component of the URI.""" 

433 return self._uri.path 

434 

435 @property 

436 def unquoted_path(self) -> str: 

437 """Return path component of the URI with any URI quoting reversed.""" 

438 return urllib.parse.unquote(self._uri.path) 

439 

440 @property 

441 def ospath(self) -> str: 

442 """Return the path component of the URI localized to current OS.""" 

443 raise AttributeError(f"Non-file URI ({self}) has no local OS path.") 

444 

445 @property 

446 def relativeToPathRoot(self) -> str: 

447 """Return path relative to network location. 

448 

449 This is the path property with posix separator stripped 

450 from the left hand side of the path. 

451 

452 Always unquotes. 

453 """ 

454 relToRoot = self.path.lstrip("/") 

455 if relToRoot == "": 

456 return "./" 

457 return urllib.parse.unquote(relToRoot) 

458 

459 @property 

460 def is_root(self) -> bool: 

461 """Return whether this URI points to the root of the network location. 

462 

463 This means that the path components refers to the top level. 

464 """ 

465 relpath = self.relativeToPathRoot 

466 if relpath == "./": 

467 return True 

468 return False 

469 

470 @property 

471 def fragment(self) -> str: 

472 """Return the fragment component of the URI. May be quoted.""" 

473 return self._uri.fragment 

474 

475 @property 

476 def unquoted_fragment(self) -> str: 

477 """Return unquoted fragment.""" 

478 return urllib.parse.unquote(self.fragment) 

479 

480 @property 

481 def params(self) -> str: 

482 """Return any parameters included in the URI.""" 

483 return self._uri.params 

484 

485 @property 

486 def query(self) -> str: 

487 """Return any query strings included in the URI.""" 

488 return self._uri.query 

489 

490 def geturl(self) -> str: 

491 """Return the URI in string form. 

492 

493 Returns 

494 ------- 

495 url : `str` 

496 String form of URI. 

497 """ 

498 return self._uri.geturl() 

499 

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

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

502 

503 Returns 

504 ------- 

505 fs : `fsspec.spec.AbstractFileSystem` 

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

507 path : `str` 

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

509 """ 

510 if fsspec is None: 

511 raise ImportError("fsspec is not available") 

512 # By default give the URL to fsspec and hope. 

513 return fsspec.url_to_fs(self.geturl()) 

514 

515 def root_uri(self) -> ResourcePath: 

516 """Return the base root URI. 

517 

518 Returns 

519 ------- 

520 uri : `ResourcePath` 

521 Root URI. 

522 """ 

523 return self.replace(path="", query="", fragment="", params="", forceDirectory=True) 

524 

525 def split(self) -> tuple[ResourcePath, str]: 

526 """Split URI into head and tail. 

527 

528 Returns 

529 ------- 

530 head: `ResourcePath` 

531 Everything leading up to tail, expanded and normalized as per 

532 ResourcePath rules. 

533 tail : `str` 

534 Last path component. Tail will be empty if path ends on a 

535 separator or if the URI is known to be associated with a directory. 

536 Tail will never contain separators. It will be unquoted. 

537 

538 Notes 

539 ----- 

540 Equivalent to `os.path.split` where head preserves the URI 

541 components. In some cases this method can result in a file system 

542 check to verify whether the URI is a directory or not (only if 

543 ``forceDirectory`` was `None` during construction). For a scheme-less 

544 URI this can mean that the result might change depending on current 

545 working directory. 

546 """ 

547 if self.isdir(): 

548 # This is known to be a directory so must return itself and 

549 # the empty string. 

550 return self, "" 

551 

552 head, tail = self._pathModule.split(self.path) 

553 headuri = self._uri._replace(path=head, fragment="", query="", params="") 

554 

555 # The file part should never include quoted metacharacters 

556 tail = urllib.parse.unquote(tail) 

557 

558 # Schemeless is special in that it can be a relative path. 

559 # We need to ensure that it stays that way. All other URIs will 

560 # be absolute already. 

561 forceAbsolute = self.isabs() 

562 return ResourcePath(headuri, forceDirectory=True, forceAbsolute=forceAbsolute), tail 

563 

564 def basename(self) -> str: 

565 """Return the base name, last element of path, of the URI. 

566 

567 Returns 

568 ------- 

569 tail : `str` 

570 Last part of the path attribute. Trail will be empty if path ends 

571 on a separator. 

572 

573 Notes 

574 ----- 

575 If URI ends on a slash returns an empty string. This is the second 

576 element returned by `split()`. 

577 

578 Equivalent of `os.path.basename`. 

579 """ 

580 return self.split()[1] 

581 

582 def dirname(self) -> ResourcePath: 

583 """Return the directory component of the path as a new `ResourcePath`. 

584 

585 Returns 

586 ------- 

587 head : `ResourcePath` 

588 Everything except the tail of path attribute, expanded and 

589 normalized as per ResourcePath rules. 

590 

591 Notes 

592 ----- 

593 Equivalent of `os.path.dirname`. If this is a directory URI it will 

594 be returned unchanged. If the parent directory is always required 

595 use `parent`. 

596 """ 

597 return self.split()[0] 

598 

599 def parent(self) -> ResourcePath: 

600 """Return a `ResourcePath` of the parent directory. 

601 

602 Returns 

603 ------- 

604 head : `ResourcePath` 

605 Everything except the tail of path attribute, expanded and 

606 normalized as per `ResourcePath` rules. 

607 

608 Notes 

609 ----- 

610 For a file-like URI this will be the same as calling `dirname`. 

611 For a directory-like URI this will always return the parent directory 

612 whereas `dirname()` will return the original URI. This is consistent 

613 with `os.path.dirname` compared to the `pathlib.Path` property 

614 ``parent``. 

615 """ 

616 if self.dirLike is False: 

617 # os.path.split() is slightly faster than calling Path().parent. 

618 return self.dirname() 

619 # When self is dir-like, returns its parent directory, 

620 # regardless of the presence of a trailing separator 

621 originalPath = self._pathLib(self.path) 

622 parentPath = originalPath.parent 

623 return self.replace(path=str(parentPath), forceDirectory=True, fragment="", query="", params="") 

624 

625 def replace( 

626 self, forceDirectory: bool | None = None, isTemporary: bool = False, **kwargs: Any 

627 ) -> ResourcePath: 

628 """Return new `ResourcePath` with specified components replaced. 

629 

630 Parameters 

631 ---------- 

632 forceDirectory : `bool` or `None`, optional 

633 Parameter passed to ResourcePath constructor to force this 

634 new URI to be dir-like or file-like. 

635 isTemporary : `bool`, optional 

636 Indicate that the resulting URI is temporary resource. 

637 **kwargs 

638 Components of a `urllib.parse.ParseResult` that should be 

639 modified for the newly-created `ResourcePath`. 

640 

641 Returns 

642 ------- 

643 new : `ResourcePath` 

644 New `ResourcePath` object with updated values. 

645 

646 Notes 

647 ----- 

648 Does not, for now, allow a change in URI scheme. 

649 """ 

650 # Disallow a change in scheme 

651 if "scheme" in kwargs: 

652 raise ValueError(f"Can not use replace() method to change URI scheme for {self}") 

653 result = self.__class__( 

654 self._uri._replace(**kwargs), forceDirectory=forceDirectory, isTemporary=isTemporary 

655 ) 

656 result._copy_extra_attributes(self) 

657 return result 

658 

659 def updatedFile(self, newfile: str) -> ResourcePath: 

660 """Return new URI with an updated final component of the path. 

661 

662 Parameters 

663 ---------- 

664 newfile : `str` 

665 File name with no path component. 

666 

667 Returns 

668 ------- 

669 updated : `ResourcePath` 

670 Updated `ResourcePath` with new updated final component. 

671 

672 Notes 

673 ----- 

674 Forces the ``ResourcePath.dirLike`` attribute to be false. The new file 

675 path will be quoted if necessary. If the current URI is known to 

676 refer to a directory, the new file will be joined to the current file. 

677 It is recommended that this behavior no longer be used and a call 

678 to `isdir` by the caller should be used to decide whether to join or 

679 replace. In the future this method may be modified to always replace 

680 the final element of the path. 

681 """ 

682 if self.dirLike: 

683 return self.join(newfile, forceDirectory=False) 

684 return self.parent().join(newfile, forceDirectory=False) 

685 

686 def updatedExtension(self, ext: str | None) -> ResourcePath: 

687 """Return a new `ResourcePath` with updated file extension. 

688 

689 All file extensions are replaced. 

690 

691 Parameters 

692 ---------- 

693 ext : `str` or `None` 

694 New extension. If an empty string is given any extension will 

695 be removed. If `None` is given there will be no change. 

696 

697 Returns 

698 ------- 

699 updated : `ResourcePath` 

700 URI with the specified extension. Can return itself if 

701 no extension was specified. 

702 """ 

703 if ext is None: 

704 return self 

705 

706 # Get the extension 

707 current = self.getExtension() 

708 

709 # Nothing to do if the extension already matches 

710 if current == ext: 

711 return self 

712 

713 # Remove the current extension from the path 

714 # .fits.gz counts as one extension do not use os.path.splitext 

715 path = self.path 

716 if current: 

717 path = path.removesuffix(current) 

718 

719 # Ensure that we have a leading "." on file extension (and we do not 

720 # try to modify the empty string) 

721 if ext and not ext.startswith("."): 

722 ext = "." + ext 

723 

724 return self.replace(path=path + ext, forceDirectory=False) 

725 

726 def getExtension(self) -> str: 

727 """Return the extension(s) associated with this URI path. 

728 

729 Returns 

730 ------- 

731 ext : `str` 

732 The file extension (including the ``.``). Can be empty string 

733 if there is no file extension. Usually returns only the last 

734 file extension unless there is a special extension modifier 

735 indicating file compression, in which case the combined 

736 extension (e.g. ``.fits.gz``) will be returned. 

737 

738 Notes 

739 ----- 

740 Does not distinguish between file and directory URIs when determining 

741 a suffix. An extension is only determined from the final component 

742 of the path. 

743 """ 

744 special = {".gz", ".bz2", ".xz", ".fz"} 

745 

746 # path lib will ignore any "." in directories. 

747 # path lib works well: 

748 # extensions = self._pathLib(self.path).suffixes 

749 # But the constructor is slow. Therefore write our own implementation. 

750 # Strip trailing separator if present, do not care if this is a 

751 # directory or not. 

752 parts = self.path.rstrip("/").rsplit(self._pathModule.sep, 1) 

753 _, *extensions = parts[-1].split(".") 

754 

755 if not extensions: 

756 return "" 

757 extensions = ["." + x for x in extensions] 

758 

759 ext = extensions.pop() 

760 

761 # Multiple extensions, decide whether to include the final two 

762 if extensions and ext in special: 

763 ext = f"{extensions[-1]}{ext}" 

764 

765 return ext 

766 

767 def join( 

768 self, path: str | ResourcePath, isTemporary: bool | None = None, forceDirectory: bool | None = None 

769 ) -> ResourcePath: 

770 """Return new `ResourcePath` with additional path components. 

771 

772 Parameters 

773 ---------- 

774 path : `str`, `ResourcePath` 

775 Additional file components to append to the current URI. Will be 

776 quoted depending on the associated URI scheme. If the path looks 

777 like a URI referring to an absolute location, it will be returned 

778 directly (matching the behavior of `os.path.join`). It can 

779 also be a `ResourcePath`. Fragments are propagated. 

780 isTemporary : `bool`, optional 

781 Indicate that the resulting URI represents a temporary resource. 

782 Default is ``self.isTemporary``. 

783 forceDirectory : `bool` or `None`, optional 

784 If `True` forces the URI to end with a separator. If `False` the 

785 resultant URI is declared to refer to a file. `None` indicates 

786 that the file directory status is unknown. 

787 

788 Returns 

789 ------- 

790 new : `ResourcePath` 

791 New URI with the path appended. 

792 

793 Notes 

794 ----- 

795 Schemeless URIs assume local path separator but all other URIs assume 

796 POSIX separator if the supplied path has directory structure. It 

797 may be this never becomes a problem but datastore templates assume 

798 POSIX separator is being used. 

799 

800 If an absolute `ResourcePath` is given for ``path`` is is assumed that 

801 this should be returned directly. Giving a ``path`` of an absolute 

802 scheme-less URI is not allowed for safety reasons as it may indicate 

803 a mistake in the calling code. 

804 

805 It is an error to attempt to join to something that is known to 

806 refer to a file. Use `updatedFile` if the file is to be 

807 replaced. 

808 

809 If an unquoted ``#`` is included in the path it is assumed to be 

810 referring to a fragment and not part of the file name. 

811 

812 Raises 

813 ------ 

814 ValueError 

815 Raised if the given path object refers to a directory but the 

816 ``forceDirectory`` parameter insists the outcome should be a file, 

817 and vice versa. Also raised if the URI being joined with is known 

818 to refer to a file. 

819 RuntimeError 

820 Raised if this attempts to join a temporary URI to a non-temporary 

821 URI. 

822 """ 

823 if self.dirLike is False: 

824 raise ValueError("Can not join a new path component to a file.") 

825 if isTemporary is None: 

826 isTemporary = self.isTemporary 

827 elif not isTemporary and self.isTemporary: 

828 raise RuntimeError("Cannot join temporary URI to non-temporary URI.") 

829 # If we have a full URI in path we will use it directly 

830 # but without forcing to absolute so that we can trap the 

831 # expected option of relative path. 

832 path_uri = ResourcePath( 

833 path, forceAbsolute=False, forceDirectory=forceDirectory, isTemporary=isTemporary 

834 ) 

835 if forceDirectory is not None and path_uri.dirLike is not forceDirectory: 

836 raise ValueError( 

837 "The supplied path URI to join has inconsistent directory state " 

838 f"with forceDirectory parameter: {path_uri.dirLike} vs {forceDirectory}" 

839 ) 

840 forceDirectory = path_uri.dirLike 

841 

842 if path_uri.isabs(): 

843 # Absolute URI so return it directly. 

844 return path_uri 

845 

846 # We want to propagate fragments to the joined path and we rely on 

847 # the ResourcePath parser to find these fragments for us even in plain 

848 # strings. Must assume there are no `#` characters in filenames. 

849 if not isinstance(path, str) or path_uri.fragment: 

850 path = path_uri.unquoted_path 

851 

852 # Might need to quote the path. 

853 if self.quotePaths: 

854 path = urllib.parse.quote(path) 

855 

856 newpath = self._pathModule.normpath(self._pathModule.join(self.path, path)) 

857 

858 # normpath can strip trailing / so we force directory if the supplied 

859 # path ended with a / 

860 has_dir_sep = path.endswith(self._pathModule.sep) 

861 if forceDirectory is None and has_dir_sep: 

862 forceDirectory = True 

863 elif forceDirectory is False and has_dir_sep: 

864 raise ValueError("Path to join has trailing / but is being forced to be a file.") 

865 return self.replace( 

866 path=newpath, 

867 forceDirectory=forceDirectory, 

868 isTemporary=isTemporary, 

869 fragment=path_uri.fragment, 

870 query=path_uri.query, 

871 params=path_uri.params, 

872 ) 

873 

874 def relative_to(self, other: ResourcePath, walk_up: bool = False) -> str | None: 

875 """Return the relative path from this URI to the other URI. 

876 

877 Parameters 

878 ---------- 

879 other : `ResourcePath` 

880 URI to use to calculate the relative path. Must be a parent 

881 of this URI. 

882 walk_up : `bool`, optional 

883 Control whether "``..``" can be used to resolve a relative path. 

884 Default is `False`. Can not be `True` on Python version 3.11. 

885 

886 Returns 

887 ------- 

888 subpath : `str` 

889 The sub path of this URI relative to the supplied other URI. 

890 Returns `None` if there is no parent child relationship. 

891 Scheme and netloc must match. 

892 """ 

893 # Scheme-less self is handled elsewhere. 

894 if self.scheme != other.scheme: 

895 return None 

896 if self.netloc != other.netloc: 

897 # Special case for localhost vs empty string. 

898 # There can be many variants of localhost. 

899 local_netlocs = {"", "localhost", "localhost.localdomain", "127.0.0.1"} 

900 if not {self.netloc, other.netloc}.issubset(local_netlocs): 

901 return None 

902 

903 # Rather than trying to guess a failure reason from the TypeError 

904 # explicitly check for python 3.11. Doing this will simplify the 

905 # rediscovery of a useless python version check when we set a new 

906 # minimum version. 

907 kwargs = {} 

908 if walk_up: 

909 if sys.version_info < (3, 12, 0): 

910 raise TypeError("walk_up parameter can not be true in python 3.11 and older") 

911 

912 kwargs["walk_up"] = True 

913 

914 enclosed_path = self._pathLib(self.relativeToPathRoot) 

915 parent_path = other.relativeToPathRoot 

916 subpath: str | None 

917 try: 

918 subpath = str(enclosed_path.relative_to(parent_path, **kwargs)) 

919 except ValueError: 

920 subpath = None 

921 else: 

922 subpath = urllib.parse.unquote(subpath) 

923 return subpath 

924 

925 def exists(self) -> bool: 

926 """Indicate that the resource is available. 

927 

928 Returns 

929 ------- 

930 exists : `bool` 

931 `True` if the resource exists. 

932 """ 

933 raise NotImplementedError() 

934 

935 @classmethod 

936 def _group_uris(cls, uris: Iterable[ResourcePath]) -> dict[type[ResourcePath], list[ResourcePath]]: 

937 """Group URIs by class/scheme.""" 

938 grouped: dict[type, list[ResourcePath]] = defaultdict(list) 

939 for uri in uris: 

940 grouped[uri.__class__].append(uri) 

941 return grouped 

942 

943 @classmethod 

944 def mexists( 

945 cls, uris: Iterable[ResourcePath], *, num_workers: int | None = None 

946 ) -> dict[ResourcePath, bool]: 

947 """Check for existence of multiple URIs at once. 

948 

949 Parameters 

950 ---------- 

951 uris : iterable of `ResourcePath` 

952 The URIs to test. 

953 num_workers : `int` or `None`, optional 

954 The number of parallel workers to use when checking for existence 

955 If `None`, the default value will be taken from the environment. 

956 If this number is higher than the default and a thread pool is 

957 used, there may not be enough cached connections available. 

958 

959 Returns 

960 ------- 

961 existence : `dict` of [`ResourcePath`, `bool`] 

962 Mapping of original URI to boolean indicating existence. 

963 """ 

964 existence: dict[ResourcePath, bool] = {} 

965 for uri_class, group in cls._group_uris(uris).items(): 

966 existence.update(uri_class._mexists(group, num_workers=num_workers)) 

967 

968 return existence 

969 

970 @classmethod 

971 def _mexists( 

972 cls, uris: Iterable[ResourcePath], *, num_workers: int | None = None 

973 ) -> dict[ResourcePath, bool]: 

974 """Check for existence of multiple URIs at once. 

975 

976 Implementation helper method for `mexists`. 

977 

978 

979 Parameters 

980 ---------- 

981 uris : iterable of `ResourcePath` 

982 The URIs to test. 

983 num_workers : `int` or `None`, optional 

984 The number of parallel workers to use when checking for existence 

985 If `None`, the default value will be taken from the environment. 

986 

987 Returns 

988 ------- 

989 existence : `dict` of [`ResourcePath`, `bool`] 

990 Mapping of original URI to boolean indicating existence. 

991 """ 

992 pool_executor_class = _get_executor_class() 

993 if issubclass(pool_executor_class, concurrent.futures.ProcessPoolExecutor): 

994 # Patch the environment to make it think there is only one worker 

995 # for each subprocess. 

996 with _patch_environ({"LSST_RESOURCES_NUM_WORKERS": "1"}): 

997 return cls._mexists_pool(pool_executor_class, uris) 

998 else: 

999 return cls._mexists_pool(pool_executor_class, uris, num_workers=num_workers) 

1000 

1001 @classmethod 

1002 def _mexists_pool( 

1003 cls, 

1004 pool_executor_class: _EXECUTOR_TYPE, 

1005 uris: Iterable[ResourcePath], 

1006 *, 

1007 num_workers: int | None = None, 

1008 ) -> dict[ResourcePath, bool]: 

1009 """Check for existence of multiple URIs at once using specified pool 

1010 executor. 

1011 

1012 Implementation helper method for `_mexists`. 

1013 

1014 Parameters 

1015 ---------- 

1016 pool_executor_class : `type` [ `concurrent.futures.Executor` ] 

1017 Type of executor pool to use. 

1018 uris : iterable of `ResourcePath` 

1019 The URIs to test. 

1020 num_workers : `int` or `None`, optional 

1021 The number of parallel workers to use when checking for existence 

1022 If `None`, the default value will be taken from the environment. 

1023 

1024 Returns 

1025 ------- 

1026 existence : `dict` of [`ResourcePath`, `bool`] 

1027 Mapping of original URI to boolean indicating existence. 

1028 """ 

1029 max_workers = num_workers if num_workers is not None else _get_num_workers() 

1030 with pool_executor_class(max_workers=max_workers) as exists_executor: 

1031 future_exists = {exists_executor.submit(uri.exists): uri for uri in uris} 

1032 

1033 results: dict[ResourcePath, bool] = {} 

1034 for future in concurrent.futures.as_completed(future_exists): 

1035 uri = future_exists[future] 

1036 try: 

1037 exists = future.result() 

1038 except Exception: 

1039 exists = False 

1040 results[uri] = exists 

1041 return results 

1042 

1043 @classmethod 

1044 def mtransfer( 

1045 cls, 

1046 transfer: str, 

1047 from_to: Iterable[tuple[ResourcePath, ResourcePath]], 

1048 overwrite: bool = False, 

1049 transaction: TransactionProtocol | None = None, 

1050 do_raise: bool = True, 

1051 ) -> dict[ResourcePath, MBulkResult]: 

1052 """Transfer many files in bulk. 

1053 

1054 Parameters 

1055 ---------- 

1056 transfer : `str` 

1057 Mode to use for transferring the resource. Generically there are 

1058 many standard options: copy, link, symlink, hardlink, relsymlink. 

1059 Not all URIs support all modes. 

1060 from_to : `list` [ `tuple` [ `ResourcePath`, `ResourcePath` ] ] 

1061 A sequence of the source URIs and the target URIs. 

1062 overwrite : `bool`, optional 

1063 Allow an existing file to be overwritten. Defaults to `False`. 

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

1065 A transaction object that can (depending on implementation) 

1066 rollback transfers on error. Not guaranteed to be implemented. 

1067 The transaction object must be thread safe. 

1068 do_raise : `bool`, optional 

1069 If `True` an `ExceptionGroup` will be raised containing any 

1070 exceptions raised by the individual transfers. If `False`, or if 

1071 there were no exceptions, a dict reporting the status of each 

1072 `ResourcePath` will be returned. 

1073 

1074 Returns 

1075 ------- 

1076 copy_status : `dict` [ `ResourcePath`, `MBulkResult` ] 

1077 A dict of all the transfer attempts with a value indicating 

1078 whether the transfer succeeded for the target URI. If ``do_raise`` 

1079 is `True`, this will only be returned if there are no errors. 

1080 """ 

1081 pool_executor_class = _get_executor_class() 

1082 if issubclass(pool_executor_class, concurrent.futures.ProcessPoolExecutor): 

1083 # Patch the environment to make it think there is only one worker 

1084 # for each subprocess. 

1085 with _patch_environ({"LSST_RESOURCES_NUM_WORKERS": "1"}): 

1086 return cls._mtransfer( 

1087 pool_executor_class, 

1088 transfer, 

1089 from_to, 

1090 overwrite=overwrite, 

1091 transaction=transaction, 

1092 do_raise=do_raise, 

1093 ) 

1094 return cls._mtransfer( 

1095 pool_executor_class, 

1096 transfer, 

1097 from_to, 

1098 overwrite=overwrite, 

1099 transaction=transaction, 

1100 do_raise=do_raise, 

1101 ) 

1102 

1103 @classmethod 

1104 def _mtransfer( 

1105 cls, 

1106 pool_executor_class: _EXECUTOR_TYPE, 

1107 transfer: str, 

1108 from_to: Iterable[tuple[ResourcePath, ResourcePath]], 

1109 overwrite: bool = False, 

1110 transaction: TransactionProtocol | None = None, 

1111 do_raise: bool = True, 

1112 ) -> dict[ResourcePath, MBulkResult]: 

1113 """Transfer many files in bulk. 

1114 

1115 Parameters 

1116 ---------- 

1117 transfer : `str` 

1118 Mode to use for transferring the resource. Generically there are 

1119 many standard options: copy, link, symlink, hardlink, relsymlink. 

1120 Not all URIs support all modes. 

1121 from_to : `list` [ `tuple` [ `ResourcePath`, `ResourcePath` ] ] 

1122 A sequence of the source URIs and the target URIs. 

1123 overwrite : `bool`, optional 

1124 Allow an existing file to be overwritten. Defaults to `False`. 

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

1126 A transaction object that can (depending on implementation) 

1127 rollback transfers on error. Not guaranteed to be implemented. 

1128 The transaction object must be thread safe. 

1129 do_raise : `bool`, optional 

1130 If `True` an `ExceptionGroup` will be raised containing any 

1131 exceptions raised by the individual transfers. Else a dict 

1132 reporting the status of each `ResourcePath` will be returned. 

1133 

1134 Returns 

1135 ------- 

1136 copy_status : `dict` [ `ResourcePath`, `MBulkResult` ] 

1137 A dict of all the transfer attempts with a value indicating 

1138 whether the transfer succeeded for the target URI. 

1139 """ 

1140 with pool_executor_class(max_workers=_get_num_workers()) as transfer_executor: 

1141 future_transfers = { 

1142 transfer_executor.submit( 

1143 to_uri.transfer_from, 

1144 from_uri, 

1145 transfer=transfer, 

1146 overwrite=overwrite, 

1147 transaction=transaction, 

1148 multithreaded=False, 

1149 ): to_uri 

1150 for from_uri, to_uri in from_to 

1151 } 

1152 results: dict[ResourcePath, MBulkResult] = {} 

1153 failed = False 

1154 for future in concurrent.futures.as_completed(future_transfers): 

1155 to_uri = future_transfers[future] 

1156 try: 

1157 future.result() 

1158 except Exception as e: 

1159 transferred = MBulkResult(False, e) 

1160 failed = True 

1161 else: 

1162 transferred = MBulkResult(True, None) 

1163 results[to_uri] = transferred 

1164 

1165 if do_raise and failed: 

1166 raise ExceptionGroup( 

1167 f"Errors transferring {len(results)} artifacts", 

1168 tuple(res.exception for res in results.values() if res.exception is not None), 

1169 ) 

1170 

1171 return results 

1172 

1173 def remove(self) -> None: 

1174 """Remove the resource.""" 

1175 raise NotImplementedError() 

1176 

1177 @classmethod 

1178 def mremove( 

1179 cls, uris: Iterable[ResourcePath], *, do_raise: bool = True 

1180 ) -> dict[ResourcePath, MBulkResult]: 

1181 """Remove multiple URIs at once. 

1182 

1183 Parameters 

1184 ---------- 

1185 uris : iterable of `ResourcePath` 

1186 URIs to remove. 

1187 do_raise : `bool`, optional 

1188 If `True` an `ExceptionGroup` will be raised containing any 

1189 exceptions raised by the individual transfers. If `False`, or if 

1190 there were no exceptions, a dict reporting the status of each 

1191 `ResourcePath` will be returned. 

1192 

1193 Returns 

1194 ------- 

1195 results : `dict` [ `ResourcePath`, `MBulkResult` ] 

1196 Dictionary mapping each URI to a result object indicating whether 

1197 the removal succeeded or resulted in an exception. If ``do_raise`` 

1198 is `True` this will only be returned if everything succeeded. 

1199 """ 

1200 # Group URIs by scheme since some URI schemes support native bulk 

1201 # APIs. 

1202 results: dict[ResourcePath, MBulkResult] = {} 

1203 for uri_class, group in cls._group_uris(uris).items(): 

1204 results.update(uri_class._mremove(group)) 

1205 if do_raise: 

1206 failed = any(not r.success for r in results.values()) 

1207 if failed: 

1208 s = "s" if len(results) != 1 else "" 

1209 raise ExceptionGroup( 

1210 f"Error{s} removing {len(results)} artifact{s}", 

1211 tuple(res.exception for res in results.values() if res.exception is not None), 

1212 ) 

1213 

1214 return results 

1215 

1216 @classmethod 

1217 def _mremove(cls, uris: Iterable[ResourcePath]) -> dict[ResourcePath, MBulkResult]: 

1218 """Remove multiple URIs using futures.""" 

1219 pool_executor_class = _get_executor_class() 

1220 if issubclass(pool_executor_class, concurrent.futures.ProcessPoolExecutor): 

1221 # Patch the environment to make it think there is only one worker 

1222 # for each subprocess. 

1223 with _patch_environ({"LSST_RESOURCES_NUM_WORKERS": "1"}): 

1224 return cls._mremove_pool(pool_executor_class, uris) 

1225 else: 

1226 return cls._mremove_pool(pool_executor_class, uris) 

1227 

1228 @classmethod 

1229 def _mremove_pool( 

1230 cls, 

1231 pool_executor_class: _EXECUTOR_TYPE, 

1232 uris: Iterable[ResourcePath], 

1233 *, 

1234 num_workers: int | None = None, 

1235 ) -> dict[ResourcePath, MBulkResult]: 

1236 """Remove URIs using a futures pool.""" 

1237 max_workers = num_workers if num_workers is not None else _get_num_workers() 

1238 results: dict[ResourcePath, MBulkResult] = {} 

1239 with pool_executor_class(max_workers=max_workers) as remove_executor: 

1240 future_remove = {remove_executor.submit(uri.remove): uri for uri in uris} 

1241 for future in concurrent.futures.as_completed(future_remove): 

1242 try: 

1243 future.result() 

1244 except Exception as e: 

1245 removed = MBulkResult(False, e) 

1246 else: 

1247 removed = MBulkResult(True, None) 

1248 uri = future_remove[future] 

1249 results[uri] = removed 

1250 return results 

1251 

1252 def isabs(self) -> bool: 

1253 """Indicate that the resource is fully specified. 

1254 

1255 For non-schemeless URIs this is always true. 

1256 

1257 Returns 

1258 ------- 

1259 isabs : `bool` 

1260 `True` in all cases except schemeless URI. 

1261 """ 

1262 return True 

1263 

1264 def abspath(self) -> ResourcePath: 

1265 """Return URI using an absolute path. 

1266 

1267 Returns 

1268 ------- 

1269 abs : `ResourcePath` 

1270 Absolute URI. For non-schemeless URIs this always returns itself. 

1271 Schemeless URIs are upgraded to file URIs. 

1272 """ 

1273 return self 

1274 

1275 @contextlib.contextmanager 

1276 def _as_local( 

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

1278 ) -> Iterator[ResourcePath]: 

1279 """Return the location of the (possibly remote) resource as local file. 

1280 

1281 This is a helper function for `as_local` context manager. 

1282 

1283 Parameters 

1284 ---------- 

1285 multithreaded : `bool`, optional 

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

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

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

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

1290 streams will be disabled. 

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

1292 Explicit override of the temporary directory to use for remote 

1293 downloads. 

1294 

1295 Returns 

1296 ------- 

1297 local_uri : `ResourcePath` 

1298 A URI to a local POSIX file. This can either be the same resource 

1299 or a local downloaded copy of the resource. 

1300 """ 

1301 raise NotImplementedError() 

1302 

1303 @contextlib.contextmanager 

1304 def as_local( 

1305 self, multithreaded: bool = True, tmpdir: ResourcePathExpression | None = None 

1306 ) -> Iterator[ResourcePath]: 

1307 """Return the location of the (possibly remote) resource as local file. 

1308 

1309 Parameters 

1310 ---------- 

1311 multithreaded : `bool`, optional 

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

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

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

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

1316 streams will be disabled. 

1317 tmpdir : `lsst.resources.ResourcePathExpression` or `None`, optional 

1318 Explicit override of the temporary directory to use for remote 

1319 downloads. This directory must be a local POSIX directory and 

1320 must exist. 

1321 

1322 Yields 

1323 ------ 

1324 local : `ResourcePath` 

1325 If this is a remote resource, it will be a copy of the resource 

1326 on the local file system, probably in a temporary directory. 

1327 For a local resource this should be the actual path to the 

1328 resource. 

1329 

1330 Notes 

1331 ----- 

1332 The context manager will automatically delete any local temporary 

1333 file. 

1334 

1335 Examples 

1336 -------- 

1337 Should be used as a context manager: 

1338 

1339 .. code-block:: py 

1340 

1341 with uri.as_local() as local: 

1342 ospath = local.ospath 

1343 """ 

1344 if self.isdir(): 

1345 raise IsADirectoryError(f"Directory-like URI {self} cannot be fetched as local.") 

1346 temp_dir = ResourcePath(tmpdir, forceDirectory=True) if tmpdir is not None else None 

1347 if temp_dir is not None and not temp_dir.isLocal: 

1348 raise ValueError(f"Temporary directory for as_local must be local resource not {temp_dir}") 

1349 with self._as_local(multithreaded=multithreaded, tmpdir=temp_dir) as local_uri: 

1350 yield local_uri 

1351 

1352 @classmethod 

1353 @contextlib.contextmanager 

1354 def temporary_uri( 

1355 cls, 

1356 prefix: ResourcePath | None = None, 

1357 suffix: str | None = None, 

1358 delete: bool = True, 

1359 ) -> Iterator[ResourcePath]: 

1360 """Create a temporary file-like URI. 

1361 

1362 Parameters 

1363 ---------- 

1364 prefix : `ResourcePath`, optional 

1365 Temporary directory to use (can be any scheme). Without this the 

1366 path will be formed as a local file URI in a temporary directory 

1367 obtained from `lsst.resources.utils.get_tempdir`. Ensuring that the 

1368 prefix location exists is the responsibility of the caller. 

1369 suffix : `str`, optional 

1370 A file suffix to be used. The ``.`` should be included in this 

1371 suffix. 

1372 delete : `bool`, optional 

1373 By default the resource will be deleted when the context manager 

1374 is exited. Setting this flag to `False` will leave the resource 

1375 alone. 

1376 

1377 Yields 

1378 ------ 

1379 uri : `ResourcePath` 

1380 The temporary URI. Will be removed when the context is completed. 

1381 """ 

1382 if prefix is None: 

1383 prefix = ResourcePath(get_tempdir(), forceDirectory=True) 

1384 

1385 # Need to create a randomized file name. For consistency do not 

1386 # use mkstemp for local and something else for remote. Additionally 

1387 # this method does not create the file to prevent name clashes. 

1388 characters = "abcdefghijklmnopqrstuvwxyz0123456789_" 

1389 rng = Random() 

1390 tempname = "".join(rng.choice(characters) for _ in range(16)) 

1391 if suffix: 

1392 tempname += suffix 

1393 temporary_uri = prefix.join(tempname, isTemporary=True) 

1394 if temporary_uri.isdir(): 

1395 # If we had a safe way to clean up a remote temporary directory, we 

1396 # could support this. 

1397 raise NotImplementedError("temporary_uri cannot be used to create a temporary directory.") 

1398 try: 

1399 yield temporary_uri 

1400 finally: 

1401 if delete: 

1402 with contextlib.suppress(FileNotFoundError): 

1403 # It's okay if this does not work because the user 

1404 # removed the file. 

1405 temporary_uri.remove() 

1406 

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

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

1409 

1410 Parameters 

1411 ---------- 

1412 size : `int`, optional 

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

1414 that all data should be read. 

1415 """ 

1416 raise NotImplementedError() 

1417 

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

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

1420 

1421 Parameters 

1422 ---------- 

1423 data : `bytes` 

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

1425 resource will be replaced. 

1426 overwrite : `bool`, optional 

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

1428 the write will fail. 

1429 """ 

1430 raise NotImplementedError() 

1431 

1432 def mkdir(self) -> None: 

1433 """For a dir-like URI, create the directory resource if needed.""" 

1434 raise NotImplementedError() 

1435 

1436 def isdir(self) -> bool: 

1437 """Return True if this URI looks like a directory, else False.""" 

1438 return bool(self.dirLike) 

1439 

1440 def size(self) -> int: 

1441 """For non-dir-like URI, return the size of the resource. 

1442 

1443 Returns 

1444 ------- 

1445 sz : `int` 

1446 The size in bytes of the resource associated with this URI. 

1447 Returns 0 if dir-like. 

1448 """ 

1449 raise NotImplementedError() 

1450 

1451 def __str__(self) -> str: 

1452 """Convert the URI to its native string form.""" 

1453 return self.geturl() 

1454 

1455 def __repr__(self) -> str: 

1456 """Return string representation suitable for evaluation.""" 

1457 return f'ResourcePath("{self.geturl()}")' 

1458 

1459 def __eq__(self, other: Any) -> bool: 

1460 """Compare supplied object with this `ResourcePath`.""" 

1461 if not isinstance(other, ResourcePath): 

1462 return NotImplemented 

1463 return self.geturl() == other.geturl() 

1464 

1465 def __hash__(self) -> int: 

1466 """Return hash of this object.""" 

1467 return hash(str(self)) 

1468 

1469 def __lt__(self, other: ResourcePath) -> bool: 

1470 return self.geturl() < other.geturl() 

1471 

1472 def __le__(self, other: ResourcePath) -> bool: 

1473 return self.geturl() <= other.geturl() 

1474 

1475 def __gt__(self, other: ResourcePath) -> bool: 

1476 return self.geturl() > other.geturl() 

1477 

1478 def __ge__(self, other: ResourcePath) -> bool: 

1479 return self.geturl() >= other.geturl() 

1480 

1481 def __copy__(self) -> ResourcePath: 

1482 """Copy constructor. 

1483 

1484 Object is immutable so copy can return itself. 

1485 """ 

1486 # Implement here because the __new__ method confuses things 

1487 return self 

1488 

1489 def __deepcopy__(self, memo: Any) -> ResourcePath: 

1490 """Deepcopy the object. 

1491 

1492 Object is immutable so copy can return itself. 

1493 """ 

1494 # Implement here because the __new__ method confuses things 

1495 return self 

1496 

1497 def __getnewargs__(self) -> tuple: 

1498 """Support pickling.""" 

1499 return (str(self),) 

1500 

1501 @classmethod 

1502 def _fixDirectorySep( 

1503 cls, parsed: urllib.parse.ParseResult, forceDirectory: bool | None = None 

1504 ) -> tuple[urllib.parse.ParseResult, bool | None]: 

1505 """Ensure that a path separator is present on directory paths. 

1506 

1507 Parameters 

1508 ---------- 

1509 parsed : `~urllib.parse.ParseResult` 

1510 The result from parsing a URI using `urllib.parse`. 

1511 forceDirectory : `bool` or `None`, optional 

1512 If `True` forces the URI to end with a separator, otherwise given 

1513 URI is interpreted as is. Specifying that the URI is conceptually 

1514 equivalent to a directory can break some ambiguities when 

1515 interpreting the last element of a path. 

1516 

1517 Returns 

1518 ------- 

1519 modified : `~urllib.parse.ParseResult` 

1520 Update result if a URI is being handled. 

1521 dirLike : `bool` or `None` 

1522 `True` if given parsed URI has a trailing separator or 

1523 ``forceDirectory`` is `True`. Otherwise returns the given value of 

1524 ``forceDirectory``. 

1525 """ 

1526 # Assume the forceDirectory flag can give us a clue. 

1527 dirLike = forceDirectory 

1528 

1529 # Directory separator 

1530 sep = cls._pathModule.sep 

1531 

1532 # URI is dir-like if explicitly stated or if it ends on a separator 

1533 endsOnSep = parsed.path.endswith(sep) 

1534 

1535 if forceDirectory is False and endsOnSep: 

1536 raise ValueError( 

1537 f"URI {parsed.geturl()} ends with {sep} but " 

1538 "forceDirectory parameter declares it to be a file." 

1539 ) 

1540 

1541 if forceDirectory or endsOnSep: 

1542 dirLike = True 

1543 # only add the separator if it's not already there 

1544 if not endsOnSep: 

1545 parsed = parsed._replace(path=parsed.path + sep) 

1546 

1547 return parsed, dirLike 

1548 

1549 @classmethod 

1550 def _fixupPathUri( 

1551 cls, 

1552 parsed: urllib.parse.ParseResult, 

1553 root: ResourcePath | None = None, 

1554 forceAbsolute: bool = False, 

1555 forceDirectory: bool | None = None, 

1556 ) -> tuple[urllib.parse.ParseResult, bool | None]: 

1557 """Correct any issues with the supplied URI. 

1558 

1559 Parameters 

1560 ---------- 

1561 parsed : `~urllib.parse.ParseResult` 

1562 The result from parsing a URI using `urllib.parse`. 

1563 root : `ResourcePath`, ignored 

1564 Not used by the this implementation since all URIs are 

1565 absolute except for those representing the local file system. 

1566 forceAbsolute : `bool`, ignored. 

1567 Not used by this implementation. URIs are generally always 

1568 absolute. 

1569 forceDirectory : `bool` or `None`, optional 

1570 If `True` forces the URI to end with a separator, otherwise given 

1571 URI is interpreted as is. Specifying that the URI is conceptually 

1572 equivalent to a directory can break some ambiguities when 

1573 interpreting the last element of a path. 

1574 

1575 Returns 

1576 ------- 

1577 modified : `~urllib.parse.ParseResult` 

1578 Update result if a URI is being handled. 

1579 dirLike : `bool` 

1580 `True` if given parsed URI has a trailing separator or 

1581 ``forceDirectory`` is `True`. Otherwise returns the given value 

1582 of ``forceDirectory``. 

1583 

1584 Notes 

1585 ----- 

1586 Relative paths are explicitly not supported by RFC8089 but `urllib` 

1587 does accept URIs of the form ``file:relative/path.ext``. They need 

1588 to be turned into absolute paths before they can be used. This is 

1589 always done regardless of the ``forceAbsolute`` parameter. 

1590 

1591 AWS S3 differentiates between keys with trailing POSIX separators (i.e 

1592 ``/dir`` and ``/dir/``) whereas POSIX does not necessarily. 

1593 

1594 Scheme-less paths are normalized. 

1595 """ 

1596 return cls._fixDirectorySep(parsed, forceDirectory) 

1597 

1598 def transfer_from( 

1599 self, 

1600 src: ResourcePath, 

1601 transfer: str, 

1602 overwrite: bool = False, 

1603 transaction: TransactionProtocol | None = None, 

1604 multithreaded: bool = True, 

1605 ) -> None: 

1606 """Transfer to this URI from another. 

1607 

1608 Parameters 

1609 ---------- 

1610 src : `ResourcePath` 

1611 Source URI. 

1612 transfer : `str` 

1613 Mode to use for transferring the resource. Generically there are 

1614 many standard options: copy, link, symlink, hardlink, relsymlink. 

1615 Not all URIs support all modes. 

1616 overwrite : `bool`, optional 

1617 Allow an existing file to be overwritten. Defaults to `False`. 

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

1619 A transaction object that can (depending on implementation) 

1620 rollback transfers on error. Not guaranteed to be implemented. 

1621 multithreaded : `bool`, optional 

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

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

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

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

1626 streams will be disabled. 

1627 

1628 Notes 

1629 ----- 

1630 Conceptually this is hard to scale as the number of URI schemes 

1631 grow. The destination URI is more important than the source URI 

1632 since that is where all the transfer modes are relevant (with the 

1633 complication that "move" deletes the source). 

1634 

1635 Local file to local file is the fundamental use case but every 

1636 other scheme has to support "copy" to local file (with implicit 

1637 support for "move") and copy from local file. 

1638 All the "link" options tend to be specific to local file systems. 

1639 

1640 "move" is a "copy" where the remote resource is deleted at the end. 

1641 Whether this works depends on the source URI rather than the 

1642 destination URI. Reverting a move on transaction rollback is 

1643 expected to be problematic if a remote resource was involved. 

1644 """ 

1645 raise NotImplementedError(f"No transfer modes supported by URI scheme {self.scheme}") 

1646 

1647 def walk( 

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

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

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

1651 

1652 Parameters 

1653 ---------- 

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

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

1656 

1657 Yields 

1658 ------ 

1659 dirpath : `ResourcePath` 

1660 Current directory being examined. 

1661 dirnames : `list` of `str` 

1662 Names of subdirectories within dirpath. 

1663 filenames : `list` of `str` 

1664 Names of all the files within dirpath. 

1665 """ 

1666 raise NotImplementedError() 

1667 

1668 @overload 

1669 @classmethod 

1670 def findFileResources( 1670 ↛ exitline 1670 didn't return from function 'findFileResources' because

1671 cls, 

1672 candidates: Iterable[ResourcePathExpression], 

1673 file_filter: str | re.Pattern | None, 

1674 grouped: Literal[True], 

1675 ) -> Iterator[Iterator[ResourcePath]]: ... 

1676 

1677 @overload 

1678 @classmethod 

1679 def findFileResources( 1679 ↛ exitline 1679 didn't return from function 'findFileResources' because

1680 cls, 

1681 candidates: Iterable[ResourcePathExpression], 

1682 *, 

1683 grouped: Literal[True], 

1684 ) -> Iterator[Iterator[ResourcePath]]: ... 

1685 

1686 @overload 

1687 @classmethod 

1688 def findFileResources( 1688 ↛ exitline 1688 didn't return from function 'findFileResources' because

1689 cls, 

1690 candidates: Iterable[ResourcePathExpression], 

1691 file_filter: str | re.Pattern | None = None, 

1692 grouped: Literal[False] = False, 

1693 ) -> Iterator[ResourcePath]: ... 

1694 

1695 @classmethod 

1696 def findFileResources( 

1697 cls, 

1698 candidates: Iterable[ResourcePathExpression], 

1699 file_filter: str | re.Pattern | None = None, 

1700 grouped: bool = False, 

1701 ) -> Iterator[ResourcePath | Iterator[ResourcePath]]: 

1702 """Get all the files from a list of values. 

1703 

1704 Parameters 

1705 ---------- 

1706 candidates : iterable [`str` or `ResourcePath`] 

1707 The files to return and directories in which to look for files to 

1708 return. 

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

1710 The regex to use when searching for files within directories. 

1711 By default returns all the found files. 

1712 grouped : `bool`, optional 

1713 If `True` the results will be grouped by directory and each 

1714 yielded value will be an iterator over URIs. If `False` each 

1715 URI will be returned separately. 

1716 

1717 Yields 

1718 ------ 

1719 found_file: `ResourcePath` 

1720 The passed-in URIs and URIs found in passed-in directories. 

1721 If grouping is enabled, each of the yielded values will be an 

1722 iterator yielding members of the group. Files given explicitly 

1723 will be returned as a single group at the end. 

1724 

1725 Notes 

1726 ----- 

1727 If a value is a file it is yielded immediately without checking that it 

1728 exists. If a value is a directory, all the files in the directory 

1729 (recursively) that match the regex will be yielded in turn. 

1730 """ 

1731 fileRegex = None if file_filter is None else re.compile(file_filter) 

1732 

1733 singles = [] 

1734 

1735 # Find all the files of interest 

1736 for location in candidates: 

1737 uri = ResourcePath(location) 

1738 if uri.isdir(): 

1739 for found in uri.walk(fileRegex): 

1740 if not found: 

1741 # This means the uri does not exist and by 

1742 # convention we ignore it 

1743 continue 

1744 root, dirs, files = found 

1745 if not files: 

1746 continue 

1747 if grouped: 

1748 yield (root.join(name) for name in files) 

1749 else: 

1750 for name in files: 

1751 yield root.join(name) 

1752 else: 

1753 if grouped: 

1754 singles.append(uri) 

1755 else: 

1756 yield uri 

1757 

1758 # Finally, return any explicitly given files in one group 

1759 if grouped and singles: 

1760 yield iter(singles) 

1761 

1762 @contextlib.contextmanager 

1763 def open( 

1764 self, 

1765 mode: str = "r", 

1766 *, 

1767 encoding: str | None = None, 

1768 prefer_file_temporary: bool = False, 

1769 ) -> Iterator[ResourceHandleProtocol]: 

1770 """Return a context manager that wraps an object that behaves like an 

1771 open file at the location of the URI. 

1772 

1773 Parameters 

1774 ---------- 

1775 mode : `str` 

1776 String indicating the mode in which to open the file. Values are 

1777 the same as those accepted by `open`, though intrinsically 

1778 read-only URI types may only support read modes, and 

1779 `io.IOBase.seekable` is not guaranteed to be `True` on the returned 

1780 object. 

1781 encoding : `str`, optional 

1782 Unicode encoding for text IO; ignored for binary IO. Defaults to 

1783 ``locale.getpreferredencoding(False)``, just as `open` 

1784 does. 

1785 prefer_file_temporary : `bool`, optional 

1786 If `True`, for implementations that require transfers from a remote 

1787 system to temporary local storage and/or back, use a temporary file 

1788 instead of an in-memory buffer; this is generally slower, but it 

1789 may be necessary to avoid excessive memory usage by large files. 

1790 Ignored by implementations that do not require a temporary. 

1791 

1792 Yields 

1793 ------ 

1794 cm : `~contextlib.AbstractContextManager` 

1795 A context manager that wraps a `ResourceHandleProtocol` file-like 

1796 object. 

1797 

1798 Notes 

1799 ----- 

1800 The default implementation of this method uses a local temporary buffer 

1801 (in-memory or file, depending on ``prefer_file_temporary``) with calls 

1802 to `read`, `write`, `as_local`, and `transfer_from` as necessary to 

1803 read and write from/to remote systems. Remote writes thus occur only 

1804 when the context manager is exited. `ResourcePath` implementations 

1805 that can return a more efficient native buffer should do so whenever 

1806 possible (as is guaranteed for local files). `ResourcePath` 

1807 implementations for which `as_local` does not return a temporary are 

1808 required to reimplement `open`, though they may delegate to `super` 

1809 when ``prefer_file_temporary`` is `False`. 

1810 """ 

1811 if self.isdir(): 

1812 raise IsADirectoryError(f"Directory-like URI {self} cannot be opened.") 

1813 if "x" in mode and self.exists(): 

1814 raise FileExistsError(f"File at {self} already exists.") 

1815 if prefer_file_temporary: 

1816 if "r" in mode or "a" in mode: 

1817 local_cm = self.as_local() 

1818 else: 

1819 local_cm = self.temporary_uri(suffix=self.getExtension()) 

1820 with local_cm as local_uri: 

1821 assert local_uri.isTemporary, ( 

1822 "ResourcePath implementations for which as_local is not " 

1823 "a temporary must reimplement `open`." 

1824 ) 

1825 with open(local_uri.ospath, mode=mode, encoding=encoding) as file_buffer: 

1826 if "a" in mode: 

1827 file_buffer.seek(0, io.SEEK_END) 

1828 yield file_buffer 

1829 if "r" not in mode or "+" in mode: 

1830 self.transfer_from(local_uri, transfer="copy", overwrite=("x" not in mode)) 

1831 else: 

1832 with self._openImpl(mode, encoding=encoding) as handle: 

1833 yield handle 

1834 

1835 @contextlib.contextmanager 

1836 def _openImpl(self, mode: str = "r", *, encoding: str | None = None) -> Iterator[ResourceHandleProtocol]: 

1837 """Implement opening of a resource handle. 

1838 

1839 This private method may be overridden by specific `ResourcePath` 

1840 implementations to provide a customized handle like interface. 

1841 

1842 Parameters 

1843 ---------- 

1844 mode : `str` 

1845 The mode the handle should be opened with 

1846 encoding : `str`, optional 

1847 The byte encoding of any binary text 

1848 

1849 Yields 

1850 ------ 

1851 handle : `~._resourceHandles.BaseResourceHandle` 

1852 A handle that conforms to the 

1853 `~._resourceHandles.BaseResourceHandle` interface 

1854 

1855 Notes 

1856 ----- 

1857 The base implementation of a file handle reads in a files entire 

1858 contents into a buffer for manipulation, and then writes it back out 

1859 upon close. Subclasses of this class may offer more fine grained 

1860 control. 

1861 """ 

1862 in_bytes = self.read() if "r" in mode or "a" in mode else b"" 

1863 if "b" in mode: 

1864 bytes_buffer = io.BytesIO(in_bytes) 

1865 bytes_buffer.name = str(self) 

1866 if "a" in mode: 

1867 bytes_buffer.seek(0, io.SEEK_END) 

1868 yield bytes_buffer 

1869 out_bytes = bytes_buffer.getvalue() 

1870 else: 

1871 if encoding is None: 

1872 encoding = locale.getpreferredencoding(False) 

1873 str_buffer = io.StringIO(in_bytes.decode(encoding)) 

1874 str_buffer.name = str(self) 

1875 if "a" in mode: 

1876 str_buffer.seek(0, io.SEEK_END) 

1877 yield str_buffer 

1878 out_bytes = str_buffer.getvalue().encode(encoding) 

1879 if "r" not in mode or "+" in mode: 

1880 self.write(out_bytes, overwrite=("x" not in mode)) 

1881 

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

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

1884 using an HTTP GET without supplying any access credentials. 

1885 

1886 Parameters 

1887 ---------- 

1888 expiration_time_seconds : `int` 

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

1890 

1891 Returns 

1892 ------- 

1893 url : `str` 

1894 HTTP URL signed for GET. 

1895 """ 

1896 raise NotImplementedError(f"URL signing is not supported for '{self.scheme}'") 

1897 

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

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

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

1901 

1902 Parameters 

1903 ---------- 

1904 expiration_time_seconds : `int` 

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

1906 

1907 Returns 

1908 ------- 

1909 url : `str` 

1910 HTTP URL signed for PUT. 

1911 """ 

1912 raise NotImplementedError(f"URL signing is not supported for '{self.scheme}'") 

1913 

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

1915 # May be overridden by subclasses to transfer attributes when a 

1916 # ResourcePath is constructed using the "clone" version of the 

1917 # ResourcePath constructor by passing in a ResourcePath object. 

1918 pass 

1919 

1920 

1921ResourcePathExpression = str | urllib.parse.ParseResult | ResourcePath | Path 

1922"""Type-annotation alias for objects that can be coerced to ResourcePath. 

1923"""