lsst-resources 29.2025.2400__tar.gz → 29.2025.2600__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {lsst_resources-29.2025.2400/python/lsst_resources.egg-info → lsst_resources-29.2025.2600}/PKG-INFO +1 -1
  2. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/doc/lsst.resources/CHANGES.rst +31 -0
  3. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/_resourcePath.py +8 -19
  4. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/dav.py +25 -9
  5. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/file.py +40 -15
  6. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/gs.py +6 -3
  7. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/http.py +21 -19
  8. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/mem.py +7 -1
  9. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/s3.py +14 -12
  10. lsst_resources-29.2025.2600/python/lsst/resources/version.py +2 -0
  11. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600/python/lsst_resources.egg-info}/PKG-INFO +1 -1
  12. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/tests/test_dav.py +6 -6
  13. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/tests/test_http.py +6 -6
  14. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/tests/test_location.py +5 -5
  15. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/tests/test_s3.py +11 -0
  16. lsst_resources-29.2025.2400/python/lsst/resources/version.py +0 -2
  17. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/COPYRIGHT +0 -0
  18. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/LICENSE +0 -0
  19. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/MANIFEST.in +0 -0
  20. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/README.md +0 -0
  21. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/doc/lsst.resources/dav.rst +0 -0
  22. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/doc/lsst.resources/index.rst +0 -0
  23. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/doc/lsst.resources/internal-api.rst +0 -0
  24. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/doc/lsst.resources/s3.rst +0 -0
  25. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/pyproject.toml +0 -0
  26. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/__init__.py +0 -0
  27. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/__init__.py +0 -0
  28. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/_resourceHandles/__init__.py +0 -0
  29. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/_resourceHandles/_baseResourceHandle.py +0 -0
  30. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/_resourceHandles/_davResourceHandle.py +0 -0
  31. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/_resourceHandles/_fileResourceHandle.py +0 -0
  32. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/_resourceHandles/_httpResourceHandle.py +0 -0
  33. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/_resourceHandles/_s3ResourceHandle.py +0 -0
  34. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/davutils.py +0 -0
  35. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/location.py +0 -0
  36. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/packageresource.py +0 -0
  37. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/py.typed +0 -0
  38. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/s3utils.py +0 -0
  39. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/schemeless.py +0 -0
  40. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/tests.py +0 -0
  41. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst/resources/utils.py +0 -0
  42. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst_resources.egg-info/SOURCES.txt +0 -0
  43. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst_resources.egg-info/dependency_links.txt +0 -0
  44. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst_resources.egg-info/requires.txt +0 -0
  45. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst_resources.egg-info/top_level.txt +0 -0
  46. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/python/lsst_resources.egg-info/zip-safe +0 -0
  47. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/setup.cfg +0 -0
  48. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/tests/test_file.py +0 -0
  49. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/tests/test_gs.py +0 -0
  50. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/tests/test_mem.py +0 -0
  51. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/tests/test_resource.py +0 -0
  52. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/tests/test_s3utils.py +0 -0
  53. {lsst_resources-29.2025.2400 → lsst_resources-29.2025.2600}/tests/test_schemeless.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-resources
3
- Version: 29.2025.2400
3
+ Version: 29.2025.2600
4
4
  Summary: An abstraction layer for reading and writing from URI file resources.
5
5
  Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
6
6
  License: BSD 3-Clause License
@@ -1,3 +1,34 @@
1
+ Resources v29.1.0 (2025-06-13)
2
+ ==============================
3
+
4
+ Miscellaneous Changes of Minor Interest
5
+ ---------------------------------------
6
+
7
+ New Features
8
+ ------------
9
+
10
+ - * Added ``ResourcePath.mtransfer()`` for doing multiple transfers in parallel.
11
+ The number of workers can be controlled using the ``$LSST_RESOURCES_NUM_WORKERS`` environment variable.
12
+ * ``transfer_from`` and ``as_local`` now have an additional parameter that can control whether implicit multithreading should be used for a single download.
13
+ * ``as_local`` has a new parameter that can be used to explicitly specify the local download location.
14
+ This can be used for ``transfer_from`` to allow the file to be downloaded to the local destination directory immediately. (`DM-31824 <https://rubinobs.atlassian.net/browse/DM-31824>`_)
15
+ - Added specialized support for schemes ``davs://`` and ``dav://`` hosted by storage endpoints implementing WebDAV protocol as described in `RFC-4918 HTTP Extensions for Web Distributed Authoring and Versioning (WebDAV) <http://www.webdav.org/specs/rfc4918.html>`_. (`DM-49784 <https://rubinobs.atlassian.net/browse/DM-49784>`_)
16
+ - Added new bulk removal API: ``ResourcePath.mremove()``.
17
+ This can be 10 times faster than calling ``remove()`` in a loop. (`DM-50724 <https://rubinobs.atlassian.net/browse/DM-50724>`_)
18
+
19
+
20
+ Miscellaneous Changes of Minor Interest
21
+ ---------------------------------------
22
+
23
+ - It is now possible to control how bulk APIs such as ``mexists()`` and ``mtransfer()`` work.
24
+ Added ``$LSST_RESOURCES_NUM_WORKERS`` environment variable to specify how many workers should be used.
25
+ The default is derived from the number of CPUs but capped at 10.
26
+ Also the ``mexists()`` method has an explicit parameter to allow the number of workers to be specified.
27
+ Added ``$LSST_RESOURCES_EXECUTOR`` to specify how the jobs should be executed.
28
+ The default is ``threads`` (which is the same as used previously) but on Linux more performance may be achievable by setting this environment variable to ``process``. (`DM-50074 <https://rubinobs.atlassian.net/browse/DM-50074>`_)
29
+ - * Fixed problem with multiple ``flush()`` calls with S3 resource handle for small chunks.
30
+ * Fixed bug in File resource handle where ``flush()`` was mistakenly calling ``close()``. (`DM-51087 <https://rubinobs.atlassian.net/browse/DM-51087>`_)
31
+
1
32
  Resources v29.0.0 (2025-03-25)
2
33
  ==============================
3
34
 
@@ -1255,7 +1255,10 @@ class ResourcePath: # numpydoc ignore=PR02
1255
1255
  """
1256
1256
  return self
1257
1257
 
1258
- def _as_local(self, multithreaded: bool = True, tmpdir: ResourcePath | None = None) -> tuple[str, bool]:
1258
+ @contextlib.contextmanager
1259
+ def _as_local(
1260
+ self, multithreaded: bool = True, tmpdir: ResourcePath | None = None
1261
+ ) -> Iterator[ResourcePath]:
1259
1262
  """Return the location of the (possibly remote) resource as local file.
1260
1263
 
1261
1264
  This is a helper function for `as_local` context manager.
@@ -1274,13 +1277,9 @@ class ResourcePath: # numpydoc ignore=PR02
1274
1277
 
1275
1278
  Returns
1276
1279
  -------
1277
- path : `str`
1278
- If this is a remote resource, it will be a copy of the resource
1279
- on the local file system, probably in a temporary directory.
1280
- For a local resource this should be the actual path to the
1281
- resource.
1282
- is_temporary : `bool`
1283
- Indicates if the local path is a temporary file or not.
1280
+ local_uri : `ResourcePath`
1281
+ A URI to a local POSIX file. This can either be the same resource
1282
+ or a local downloaded copy of the resource.
1284
1283
  """
1285
1284
  raise NotImplementedError()
1286
1285
 
@@ -1330,18 +1329,8 @@ class ResourcePath: # numpydoc ignore=PR02
1330
1329
  temp_dir = ResourcePath(tmpdir, forceDirectory=True) if tmpdir is not None else None
1331
1330
  if temp_dir is not None and not temp_dir.isLocal:
1332
1331
  raise ValueError(f"Temporary directory for as_local must be local resource not {temp_dir}")
1333
- local_src, is_temporary = self._as_local(multithreaded=multithreaded, tmpdir=temp_dir)
1334
- local_uri = ResourcePath(local_src, isTemporary=is_temporary)
1335
-
1336
- try:
1332
+ with self._as_local(multithreaded=multithreaded, tmpdir=temp_dir) as local_uri:
1337
1333
  yield local_uri
1338
- finally:
1339
- # The caller might have relocated the temporary file.
1340
- # Do not ever delete if the temporary matches self
1341
- # (since it may have been that a temporary file was made local
1342
- # but already was local).
1343
- if self != local_uri and is_temporary and local_uri.exists():
1344
- local_uri.remove()
1345
1334
 
1346
1335
  @classmethod
1347
1336
  @contextlib.contextmanager
@@ -172,7 +172,21 @@ dav_globals: DavGlobals = DavGlobals()
172
172
 
173
173
 
174
174
  class DavResourcePath(ResourcePath):
175
- """WebDAV resource."""
175
+ """WebDAV resource.
176
+
177
+ Parameters
178
+ ----------
179
+ uri : `ResourcePathExpression`
180
+ URI to store in object.
181
+ root : `str` or `ResourcePath` or `None`, optional
182
+ Root for relative URIs. Not used in this constructor.
183
+ forceAbsolute : `bool`
184
+ Whether to force absolute URI. A WebDAV URI is always absolute.
185
+ forceDirectory : `bool` or `None`, optional
186
+ Whether this URI represents a directory.
187
+ isTemporary : `bool` or `None`, optional
188
+ Whether this URI represents a temporary resource.
189
+ """
176
190
 
177
191
  def __init__(
178
192
  self,
@@ -382,7 +396,10 @@ class DavResourcePath(ResourcePath):
382
396
  headers.update({"Accept-Encoding": "identity"})
383
397
  return self._client.read_range(self._internal_url, start=start, end=end, headers=headers)
384
398
 
385
- def _as_local(self, multithreaded: bool = True, tmpdir: ResourcePath | None = None) -> tuple[str, bool]:
399
+ @contextlib.contextmanager
400
+ def _as_local(
401
+ self, multithreaded: bool = True, tmpdir: ResourcePath | None = None
402
+ ) -> Iterator[ResourcePath]:
386
403
  """Download object and place in temporary directory.
387
404
 
388
405
  Parameters
@@ -399,10 +416,9 @@ class DavResourcePath(ResourcePath):
399
416
 
400
417
  Returns
401
418
  -------
402
- path : `str`
403
- Path to local temporary file.
404
- temporary : `bool`
405
- Always returns `True`. This is always a temporary file.
419
+ local_uri : `ResourcePath`
420
+ A URI to a local POSIX file corresponding to a local temporary
421
+ downloaded copy of the resource.
406
422
  """
407
423
  # We need to ensure that this resource is actually a file. dCache
408
424
  # responds with a HTML-formatted content to a HTTP GET request to a
@@ -417,9 +433,9 @@ class DavResourcePath(ResourcePath):
417
433
  else:
418
434
  buffer_size = _calc_tmpdir_buffer_size(tmpdir.ospath)
419
435
 
420
- with ResourcePath.temporary_uri(suffix=self.getExtension(), prefix=tmpdir, delete=False) as tmp_uri:
436
+ with ResourcePath.temporary_uri(suffix=self.getExtension(), prefix=tmpdir, delete=True) as tmp_uri:
421
437
  self._client.download(self._internal_url, tmp_uri.ospath, buffer_size)
422
- return tmp_uri.ospath, True
438
+ yield tmp_uri
423
439
 
424
440
  def write(self, data: BinaryIO | bytes, overwrite: bool = True) -> None:
425
441
  """Write the supplied bytes to the new resource.
@@ -470,7 +486,7 @@ class DavResourcePath(ResourcePath):
470
486
 
471
487
  Parameters
472
488
  ----------
473
- recursive: `bool`
489
+ recursive : `bool`
474
490
  If `True` recursively remove all files and directories under this
475
491
  directory.
476
492
 
@@ -21,6 +21,7 @@ import os.path
21
21
  import posixpath
22
22
  import re
23
23
  import shutil
24
+ import stat
24
25
  import urllib.parse
25
26
  from collections.abc import Iterator
26
27
  from typing import IO, TYPE_CHECKING
@@ -79,7 +80,10 @@ class FileResourcePath(ResourcePath):
79
80
  """Remove the resource."""
80
81
  os.remove(self.ospath)
81
82
 
82
- def _as_local(self, multithreaded: bool = True, tmpdir: ResourcePath | None = None) -> tuple[str, bool]:
83
+ @contextlib.contextmanager
84
+ def _as_local(
85
+ self, multithreaded: bool = True, tmpdir: ResourcePath | None = None
86
+ ) -> Iterator[ResourcePath]:
83
87
  """Return the local path of the file.
84
88
 
85
89
  This is an internal helper for ``as_local()``.
@@ -93,12 +97,10 @@ class FileResourcePath(ResourcePath):
93
97
 
94
98
  Returns
95
99
  -------
96
- path : `str`
97
- The local path to this file.
98
- temporary : `bool`
99
- Always returns the temporary nature of the input file resource.
100
+ local_uri : `ResourcePath`
101
+ A local URI. In this case it will be itself.
100
102
  """
101
- return self.ospath, self.isTemporary
103
+ yield self
102
104
 
103
105
  def read(self, size: int = -1) -> bytes:
104
106
  with open(self.ospath, "rb") as fh:
@@ -327,15 +329,38 @@ class FileResourcePath(ResourcePath):
327
329
  # the same output directory. This at least guarantees that
328
330
  # if multiple processes are writing to the same file
329
331
  # simultaneously the file we end up with will not be corrupt.
330
- with self.temporary_uri(prefix=self.parent(), suffix=self.getExtension()) as temp_copy:
331
- shutil.copy(local_src, temp_copy.ospath)
332
- with transaction.undoWith(f"copy from {local_src}", os.remove, newFullPath):
333
- # os.rename works even if the file exists.
334
- # It's possible that another process has copied a file
335
- # in whilst this one was copying. If overwrite
336
- # protection is needed then another stat() call should
337
- # happen here.
338
- os.rename(temp_copy.ospath, newFullPath)
332
+ if overwrite:
333
+ with self.temporary_uri(prefix=self.parent(), suffix=self.getExtension()) as temp_copy:
334
+ shutil.copy(local_src, temp_copy.ospath)
335
+ with transaction.undoWith(f"copy from {local_src}", os.remove, newFullPath):
336
+ os.rename(temp_copy.ospath, newFullPath)
337
+ else:
338
+ # Create the file exclusively to ensure that no others are
339
+ # trying to write.
340
+ temp_path = newFullPath + ".transfer-tmp"
341
+ try:
342
+ with open(temp_path, "x"):
343
+ pass
344
+ except FileExistsError:
345
+ raise FileExistsError(
346
+ f"Another process is writing to '{self}'."
347
+ f" Transfer from {src} cannot be completed."
348
+ )
349
+ with transaction.undoWith(f"copy from {local_src}", os.remove, temp_path):
350
+ # Make sure file is writable, no matter the umask.
351
+ st = os.stat(temp_path)
352
+ os.chmod(temp_path, st.st_mode | stat.S_IWUSR)
353
+ shutil.copy(local_src, temp_path)
354
+ # Use link/remove to atomically and exclusively move the
355
+ # file into place (only one concurrent linker can win).
356
+ try:
357
+ os.link(temp_path, newFullPath)
358
+ except FileExistsError:
359
+ raise FileExistsError(
360
+ f"Another process wrote to '{self}'. Transfer from {src} cannot be completed."
361
+ )
362
+ finally:
363
+ os.remove(temp_path)
339
364
  elif transfer == "link":
340
365
  # Try hard link and if that fails use a symlink
341
366
  with transaction.undoWith(f"link to {local_src}", os.remove, newFullPath):
@@ -202,17 +202,20 @@ class GSResourcePath(ResourcePath):
202
202
  # Should this method do anything at all?
203
203
  self.blob.upload_from_string(b"", retry=_RETRY_POLICY)
204
204
 
205
- def _as_local(self, multithreaded: bool = True, tmpdir: ResourcePath | None = None) -> tuple[str, bool]:
205
+ @contextlib.contextmanager
206
+ def _as_local(
207
+ self, multithreaded: bool = True, tmpdir: ResourcePath | None = None
208
+ ) -> Iterator[ResourcePath]:
206
209
  with (
207
- ResourcePath.temporary_uri(prefix=tmpdir, suffix=self.getExtension(), delete=False) as tmp_uri,
210
+ ResourcePath.temporary_uri(prefix=tmpdir, suffix=self.getExtension(), delete=True) as tmp_uri,
208
211
  time_this(log, msg="Downloading %s to local file", args=(self,)),
209
212
  ):
210
213
  try:
211
214
  with tmp_uri.open("wb") as tmpFile:
212
215
  self.blob.download_to_file(tmpFile, retry=_RETRY_POLICY)
216
+ yield tmp_uri
213
217
  except NotFound as e:
214
218
  raise FileNotFoundError(f"No such resource: {self}") from e
215
- return tmp_uri.ospath, True
216
219
 
217
220
  def transfer_from(
218
221
  self,
@@ -1531,7 +1531,10 @@ class HttpResourcePath(ResourcePath):
1531
1531
  except json.JSONDecodeError:
1532
1532
  raise ValueError(f"could not deserialize response to POST request for URL {self}")
1533
1533
 
1534
- def _as_local(self, multithreaded: bool = True, tmpdir: ResourcePath | None = None) -> tuple[str, bool]:
1534
+ @contextlib.contextmanager
1535
+ def _as_local(
1536
+ self, multithreaded: bool = True, tmpdir: ResourcePath | None = None
1537
+ ) -> Iterator[ResourcePath]:
1535
1538
  """Download object over HTTP and place in temporary directory.
1536
1539
 
1537
1540
  Parameters
@@ -1548,10 +1551,9 @@ class HttpResourcePath(ResourcePath):
1548
1551
 
1549
1552
  Returns
1550
1553
  -------
1551
- path : `str`
1552
- Path to local temporary file.
1553
- temporary : `bool`
1554
- Always returns `True`. This is always a temporary file.
1554
+ local_uri : `ResourcePath`
1555
+ A URI to a local POSIX file corresponding to a local temporary
1556
+ downloaded copy of the resource.
1555
1557
  """
1556
1558
  # Use the session as a context manager to ensure that connections
1557
1559
  # to both the front end and back end servers are closed after the
@@ -1570,7 +1572,7 @@ class HttpResourcePath(ResourcePath):
1570
1572
  buffer_size = _calc_tmpdir_buffer_size(tmpdir.ospath)
1571
1573
 
1572
1574
  with ResourcePath.temporary_uri(
1573
- suffix=self.getExtension(), prefix=tmpdir, delete=False
1575
+ suffix=self.getExtension(), prefix=tmpdir, delete=True
1574
1576
  ) as tmp_uri:
1575
1577
  expected_length = int(resp.headers.get("Content-Length", "-1"))
1576
1578
  with time_this(
@@ -1586,20 +1588,20 @@ class HttpResourcePath(ResourcePath):
1586
1588
  tmpFile.write(chunk)
1587
1589
  content_length += len(chunk)
1588
1590
 
1589
- # Check that the expected and actual content lengths match. Perform
1590
- # this check only when the contents of the file was not encoded by
1591
- # the server.
1592
- if (
1593
- "Content-Encoding" not in resp.headers
1594
- and expected_length >= 0
1595
- and expected_length != content_length
1596
- ):
1597
- raise ValueError(
1598
- f"Size of downloaded file does not match value in Content-Length header for {self}: "
1599
- f"expecting {expected_length} and got {content_length} bytes"
1600
- )
1591
+ # Check that the expected and actual content lengths match.
1592
+ # Perform this check only when the contents of the file was not
1593
+ # encoded by the server.
1594
+ if (
1595
+ "Content-Encoding" not in resp.headers
1596
+ and expected_length >= 0
1597
+ and expected_length != content_length
1598
+ ):
1599
+ raise ValueError(
1600
+ f"Size of downloaded file does not match value in Content-Length header for {self}: "
1601
+ f"expecting {expected_length} and got {content_length} bytes"
1602
+ )
1601
1603
 
1602
- return tmpFile.name, True
1604
+ yield tmp_uri
1603
1605
 
1604
1606
  def _send_webdav_request(
1605
1607
  self,
@@ -13,6 +13,9 @@ from __future__ import annotations
13
13
 
14
14
  __all__ = ("InMemoryResourcePath",)
15
15
 
16
+ import contextlib
17
+ from collections.abc import Iterator
18
+
16
19
  from ._resourcePath import ResourcePath
17
20
 
18
21
 
@@ -27,5 +30,8 @@ class InMemoryResourcePath(ResourcePath):
27
30
  """Test for existence and always return False."""
28
31
  return True
29
32
 
30
- def _as_local(self, multithreaded: bool = True, tmpdir: ResourcePath | None = None) -> tuple[str, bool]:
33
+ @contextlib.contextmanager
34
+ def _as_local(
35
+ self, multithreaded: bool = True, tmpdir: ResourcePath | None = None
36
+ ) -> Iterator[ResourcePath]:
31
37
  raise RuntimeError(f"Do not know how to retrieve data for URI '{self}'")
@@ -477,7 +477,10 @@ class S3ResourcePath(ResourcePath):
477
477
 
478
478
  return s3, f"{self._bucket}/{self.relativeToPathRoot}"
479
479
 
480
- def _as_local(self, multithreaded: bool = True, tmpdir: ResourcePath | None = None) -> tuple[str, bool]:
480
+ @contextlib.contextmanager
481
+ def _as_local(
482
+ self, multithreaded: bool = True, tmpdir: ResourcePath | None = None
483
+ ) -> Iterator[ResourcePath]:
481
484
  """Download object from S3 and place in temporary directory.
482
485
 
483
486
  Parameters
@@ -494,13 +497,12 @@ class S3ResourcePath(ResourcePath):
494
497
 
495
498
  Returns
496
499
  -------
497
- path : `str`
498
- Path to local temporary file.
499
- temporary : `bool`
500
- Always returns `True`. This is always a temporary file.
500
+ local_uri : `ResourcePath`
501
+ A URI to a local POSIX file corresponding to a local temporary
502
+ downloaded copy of the resource.
501
503
  """
502
504
  with (
503
- ResourcePath.temporary_uri(prefix=tmpdir, suffix=self.getExtension(), delete=False) as tmp_uri,
505
+ ResourcePath.temporary_uri(prefix=tmpdir, suffix=self.getExtension(), delete=True) as tmp_uri,
504
506
  self._use_threads_temp_override(multithreaded),
505
507
  time_this(log, msg="Downloading %s to local file", args=(self,)),
506
508
  ):
@@ -511,7 +513,7 @@ class S3ResourcePath(ResourcePath):
511
513
  )
512
514
  with tmp_uri.open("wb") as tmpFile:
513
515
  self._download_file(tmpFile, progress)
514
- return tmp_uri.ospath, True
516
+ yield tmp_uri
515
517
 
516
518
  @backoff.on_exception(backoff.expo, all_retryable_errors, max_time=max_retry_time)
517
519
  def _upload_file(self, local_file: ResourcePath, progress: ProgressPercentage | None) -> None:
@@ -542,7 +544,7 @@ class S3ResourcePath(ResourcePath):
542
544
  try:
543
545
  self.client.copy_object(CopySource=copy_source, Bucket=self._bucket, Key=self.relativeToPathRoot)
544
546
  except (self.client.exceptions.NoSuchKey, self.client.exceptions.NoSuchBucket) as err:
545
- raise FileNotFoundError(f"No such resource to transfer: {self}") from err
547
+ raise FileNotFoundError(f"No such resource to transfer: {src} -> {self}") from err
546
548
  except ClientError as err:
547
549
  translate_client_error(err, self)
548
550
  raise
@@ -609,10 +611,10 @@ class S3ResourcePath(ResourcePath):
609
611
  timer_msg = "Transfer from %s to %s"
610
612
  timer_args = (src, self)
611
613
 
612
- if isinstance(src, type(self)):
613
- # Looks like an S3 remote uri so we can use direct copy
614
- # note that boto3.resource.meta.copy is cleverer than the low
615
- # level copy_object
614
+ if isinstance(src, type(self)) and self.client == src.client:
615
+ # Looks like an S3 remote uri so we can use direct copy.
616
+ # This only works if the source and destination are using the same
617
+ # S3 endpoint and profile.
616
618
  with time_this(log, msg=timer_msg, args=timer_args):
617
619
  self._copy_from(src)
618
620
 
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "29.2025.2600"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-resources
3
- Version: 29.2025.2400
3
+ Version: 29.2025.2600
4
4
  Summary: An abstraction layer for reading and writing from URI file resources.
5
5
  Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
6
6
  License: BSD 3-Clause License
@@ -284,12 +284,12 @@ class DavReadWriteTestCase(GenericReadWriteTestCase, unittest.TestCase):
284
284
  self.assertTrue(remote_file.exists())
285
285
  self.assertEqual(remote_file.size(), len(contents))
286
286
 
287
- local_path, is_temp = remote_file._as_local()
288
- self.assertTrue(is_temp)
289
- self.assertTrue(os.path.exists(local_path))
290
- self.assertTrue(os.stat(local_path).st_size, len(contents))
291
- self.assertEqual(ResourcePath(local_path).read(), contents)
292
- os.remove(local_path)
287
+ with remote_file._as_local() as local_uri:
288
+ self.assertTrue(local_uri.isTemporary)
289
+ self.assertTrue(os.path.exists(local_uri.ospath))
290
+ self.assertTrue(os.stat(local_uri.ospath).st_size, len(contents))
291
+ self.assertEqual(local_uri.read(), contents)
292
+ self.assertFalse(local_uri.exists())
293
293
 
294
294
  def test_dav_size(self):
295
295
  # Retrieving the size of a non-existent file must raise.
@@ -315,12 +315,12 @@ class HttpReadWriteWebdavTestCase(GenericReadWriteTestCase, unittest.TestCase):
315
315
  remote_file = self.tmpdir.join(self._get_file_name())
316
316
  self.assertIsNone(remote_file.write(data=contents, overwrite=True))
317
317
 
318
- local_path, is_temp = remote_file._as_local()
319
- self.assertTrue(is_temp)
320
- self.assertTrue(os.path.exists(local_path))
321
- self.assertTrue(os.stat(local_path).st_size, len(contents))
322
- self.assertEqual(ResourcePath(local_path).read(), contents)
323
- os.remove(local_path)
318
+ with remote_file._as_local() as local_uri:
319
+ self.assertTrue(local_uri.isTemporary)
320
+ self.assertTrue(os.path.exists(local_uri.ospath))
321
+ self.assertTrue(os.stat(local_uri.ospath).st_size, len(contents))
322
+ self.assertEqual(local_uri.read(), contents)
323
+ self.assertFalse(local_uri.exists())
324
324
 
325
325
  def test_dav_size(self):
326
326
  # Size of a non-existent file must raise.
@@ -98,7 +98,7 @@ class LocationTestCase(unittest.TestCase):
98
98
 
99
99
  for uriInfo in uriStrings:
100
100
  uri = ResourcePath(uriInfo[0], root=testRoot, forceAbsolute=uriInfo[1], forceDirectory=uriInfo[2])
101
- with self.subTest(in_uri=uriInfo[0], out_uri=uri):
101
+ with self.subTest(in_uri=repr(uriInfo[0]), out_uri=repr(uri)):
102
102
  self.assertEqual(uri.scheme, uriInfo[3], "test scheme")
103
103
  self.assertEqual(uri.netloc, uriInfo[4], "test netloc")
104
104
  self.assertEqual(uri.path, uriInfo[5], "test path")
@@ -115,7 +115,7 @@ class LocationTestCase(unittest.TestCase):
115
115
 
116
116
  for uriInfo in uriStrings:
117
117
  uri = ResourcePath(uriInfo[0], forceAbsolute=uriInfo[1], forceDirectory=uriInfo[2])
118
- with self.subTest(in_uri=uriInfo[0], out_uri=uri):
118
+ with self.subTest(in_uri=repr(uriInfo[0]), out_uri=repr(uri)):
119
119
  self.assertEqual(uri.scheme, uriInfo[3], "test scheme")
120
120
  self.assertEqual(uri.netloc, uriInfo[4], "test netloc")
121
121
  # Use ospath here to ensure that we have unquoted any
@@ -133,7 +133,7 @@ class LocationTestCase(unittest.TestCase):
133
133
 
134
134
  for uriInfo in uriStrings:
135
135
  uri = ResourcePath(uriInfo[0], forceAbsolute=False).updatedFile(uriInfo[1])
136
- with self.subTest(in_uri=uriInfo[0], out_uri=uri):
136
+ with self.subTest(in_uri=repr(uriInfo[0]), out_uri=repr(uri)):
137
137
  self.assertEqual(uri.path, uriInfo[2])
138
138
 
139
139
  # Check that schemeless can become file scheme.
@@ -333,7 +333,7 @@ class LocationTestCase(unittest.TestCase):
333
333
  """Test round tripping of the posix to os.path conversion helpers."""
334
334
  testPaths = ("/a/b/c.e", "a/b", "a/b/", "/a/b", "/a/b/", "a/b/c.e")
335
335
  for p in testPaths:
336
- with self.subTest(path=p):
336
+ with self.subTest(path=repr(p)):
337
337
  self.assertEqual(os2posix(posix2os(p)), p)
338
338
 
339
339
  def testSplit(self):
@@ -368,7 +368,7 @@ class LocationTestCase(unittest.TestCase):
368
368
  )
369
369
 
370
370
  for p, e in zip(testPaths, expected, strict=True):
371
- with self.subTest(path=p):
371
+ with self.subTest(path=repr(p)):
372
372
  uri = ResourcePath(p, testRoot)
373
373
  head, tail = uri.split()
374
374
  self.assertEqual((head.geturl(), tail), e)
@@ -351,6 +351,17 @@ class S3WithProfileReadWriteTestCase(S3ReadWriteTestCaseBase, unittest.TestCase)
351
351
  self.assertEqual(path2._bucket, "ceph:bucket2")
352
352
  self.assertIsNone(path2._profile)
353
353
 
354
+ def test_transfer_from_different_endpoints(self):
355
+ # Create a bucket using a different endpoint (the default endpoint.)
356
+ boto3.resource("s3").create_bucket(Bucket="source-bucket")
357
+ source_path = ResourcePath("s3://source-bucket/file.txt")
358
+ source_path.write(b"123")
359
+ target_path = ResourcePath(f"s3://{self.netloc}/target.txt")
360
+ # Transfer from default endpoint to custom endpoint with custom
361
+ # profile.
362
+ target_path.transfer_from(source_path)
363
+ self.assertEqual(target_path.read(), b"123")
364
+
354
365
 
355
366
  if __name__ == "__main__":
356
367
  unittest.main()
@@ -1,2 +0,0 @@
1
- __all__ = ["__version__"]
2
- __version__ = "29.2025.2400"