lsst-resources 29.2025.4800__tar.gz → 30.0.0rc3__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 (56) hide show
  1. {lsst_resources-29.2025.4800/python/lsst_resources.egg-info → lsst_resources-30.0.0rc3}/PKG-INFO +1 -1
  2. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/doc/lsst.resources/CHANGES.rst +30 -0
  3. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/_resourcePath.py +15 -1
  4. lsst_resources-30.0.0rc3/python/lsst/resources/eups.py +101 -0
  5. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/packageresource.py +2 -0
  6. lsst_resources-30.0.0rc3/python/lsst/resources/proxied.py +148 -0
  7. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/schemeless.py +17 -5
  8. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/tests.py +14 -3
  9. lsst_resources-30.0.0rc3/python/lsst/resources/version.py +2 -0
  10. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3/python/lsst_resources.egg-info}/PKG-INFO +1 -1
  11. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst_resources.egg-info/SOURCES.txt +3 -0
  12. lsst_resources-30.0.0rc3/tests/test_eups.py +239 -0
  13. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/tests/test_file.py +4 -4
  14. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/tests/test_s3.py +6 -0
  15. lsst_resources-29.2025.4800/python/lsst/resources/version.py +0 -2
  16. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/COPYRIGHT +0 -0
  17. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/LICENSE +0 -0
  18. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/MANIFEST.in +0 -0
  19. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/README.md +0 -0
  20. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/doc/lsst.resources/dav.rst +0 -0
  21. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/doc/lsst.resources/index.rst +0 -0
  22. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/doc/lsst.resources/internal-api.rst +0 -0
  23. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/doc/lsst.resources/s3.rst +0 -0
  24. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/pyproject.toml +0 -0
  25. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/__init__.py +0 -0
  26. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/__init__.py +0 -0
  27. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/_resourceHandles/__init__.py +0 -0
  28. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/_resourceHandles/_baseResourceHandle.py +0 -0
  29. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/_resourceHandles/_davResourceHandle.py +0 -0
  30. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/_resourceHandles/_fileResourceHandle.py +0 -0
  31. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/_resourceHandles/_httpResourceHandle.py +0 -0
  32. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/_resourceHandles/_s3ResourceHandle.py +0 -0
  33. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/dav.py +0 -0
  34. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/davutils.py +0 -0
  35. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/file.py +0 -0
  36. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/gs.py +0 -0
  37. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/http.py +0 -0
  38. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/location.py +0 -0
  39. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/mem.py +0 -0
  40. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/py.typed +0 -0
  41. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/s3.py +0 -0
  42. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/s3utils.py +0 -0
  43. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst/resources/utils.py +0 -0
  44. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst_resources.egg-info/dependency_links.txt +0 -0
  45. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst_resources.egg-info/requires.txt +0 -0
  46. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst_resources.egg-info/top_level.txt +0 -0
  47. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/python/lsst_resources.egg-info/zip-safe +0 -0
  48. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/setup.cfg +0 -0
  49. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/tests/test_dav.py +0 -0
  50. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/tests/test_gs.py +0 -0
  51. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/tests/test_http.py +0 -0
  52. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/tests/test_location.py +0 -0
  53. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/tests/test_mem.py +0 -0
  54. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/tests/test_resource.py +0 -0
  55. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/tests/test_s3utils.py +0 -0
  56. {lsst_resources-29.2025.4800 → lsst_resources-30.0.0rc3}/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.4800
3
+ Version: 30.0.0rc3
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-Expression: BSD-3-Clause
@@ -1,3 +1,33 @@
1
+ Resources v30.0.0 (2026-01-15)
2
+ ==============================
3
+
4
+ New Features
5
+ ------------
6
+
7
+ - Added ``walk_up`` parameter to ``ResourcePath.relative_to`` to match the behavior of ``Path.relative_to``.
8
+ Using ``walk_up=True`` allows ``..`` to be present in the returned string.
9
+ The default is `False` to remain compatible with previous versions.
10
+ This parameter can only be `True` for Python 3.12 and newer. (`DM-41256 <https://rubinobs.atlassian.net/browse/DM-41256>`_)
11
+ - Added ``eups:`` URI scheme.
12
+ This scheme will use the EUPS ``$<PRODUCT>_DIR`` environment variables if set, else try to find the corresponding Python package and look for a ``resources`` directory inside it following the decisions made in `RFC-1090 <https://rubinobs.atlassian.net/browse/RFC-1090>`_.
13
+ Additionally, if a URI starts with ``$PRODUCT_DIR`` it is automatically converted to an EUPS URI. (`DM-50997 <https://rubinobs.atlassian.net/browse/DM-50997>`_)
14
+
15
+
16
+ Bug Fixes
17
+ ---------
18
+
19
+ - Fixed ``ResourcePath.write`` for schemeless URIs that had no directory component. (`DM-41256 <https://rubinobs.atlassian.net/browse/DM-41256>`_)
20
+ - Fixed an issue where ``S3ResourcePath.transfer_from(S3ResourcePath)`` would fail when the source and destination were using different S3 endpoints or sets of credentials. (`DM-51384 <https://rubinobs.atlassian.net/browse/DM-51384>`_)
21
+ - Two simultaneous copies without overwrite could both succeed in certain cases.
22
+ This race condition has now been eliminated. (`DM-51540 <https://rubinobs.atlassian.net/browse/DM-51540>`_)
23
+
24
+
25
+ Miscellaneous Changes of Minor Interest
26
+ ---------------------------------------
27
+
28
+ - Fixed bug where ``.parents()`` and ``.dirname()`` were incorrectly returning the fragment of the file. (`DM-51890 <https://rubinobs.atlassian.net/browse/DM-51890>`_)
29
+
30
+
1
31
  Resources v29.1.0 (2025-06-13)
2
32
  ==============================
3
33
 
@@ -386,6 +386,11 @@ class ResourcePath: # numpydoc ignore=PR02
386
386
  from .mem import InMemoryResourcePath
387
387
 
388
388
  subclass = InMemoryResourcePath
389
+ elif parsed.scheme == "eups":
390
+ # EUPS package root.
391
+ from .eups import EupsResourcePath
392
+
393
+ subclass = EupsResourcePath
389
394
  else:
390
395
  raise NotImplementedError(
391
396
  f"No URI support for scheme: '{parsed.scheme}' in {parsed.geturl()}"
@@ -396,11 +401,15 @@ class ResourcePath: # numpydoc ignore=PR02
396
401
  )
397
402
 
398
403
  # It is possible for the class to change from schemeless
399
- # to file so handle that
404
+ # to file or eups so handle that
400
405
  if parsed.scheme == "file":
401
406
  from .file import FileResourcePath
402
407
 
403
408
  subclass = FileResourcePath
409
+ elif parsed.scheme == "eups":
410
+ from .eups import EupsResourcePath
411
+
412
+ subclass = EupsResourcePath
404
413
 
405
414
  # Now create an instance of the correct subclass and set the
406
415
  # attributes directly
@@ -410,8 +419,13 @@ class ResourcePath: # numpydoc ignore=PR02
410
419
  if isTemporary is None:
411
420
  isTemporary = False
412
421
  self.isTemporary = isTemporary
422
+ self._set_proxy()
413
423
  return self
414
424
 
425
+ def _set_proxy(self) -> None:
426
+ """Calculate internal proxy for externally visible resource path."""
427
+ pass
428
+
415
429
  @property
416
430
  def scheme(self) -> str:
417
431
  """Return the URI scheme.
@@ -0,0 +1,101 @@
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
+
12
+ from __future__ import annotations
13
+
14
+ __all__ = ("EupsResourcePath",)
15
+
16
+ import logging
17
+ import posixpath
18
+ import urllib.parse
19
+
20
+ from lsst.utils import getPackageDir
21
+
22
+ from ._resourcePath import ResourcePath
23
+ from .proxied import ProxiedResourcePath
24
+ from .utils import os2posix
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+
29
+ class EupsResourcePath(ProxiedResourcePath):
30
+ """URI referring to an EUPS package.
31
+
32
+ These URIs look like: ``eups://daf_butler/configs/file.yaml``
33
+ where the network location is the EUPS package name.
34
+
35
+ Internally they are proxied by either ``file`` URIs or ``resource`` URIs.
36
+ If an ``{product}_DIR`` environment variable is found it will be used
37
+ and internally a ``file`` URI will be created. If no environment variable
38
+ is found an attempt will be made to convert the EUPS product name to
39
+ a python package and a ``resource`` URI will be returned with
40
+ ``/resources`` appended. The convention is that any package supporting
41
+ an EUPS URI outside an EUPS environment will also have made available
42
+ the support files as package resources.
43
+
44
+ If it is known that the package supports package resources it is always
45
+ better to use that URI form explicitly since it is more robust since
46
+ not all EUPS packages can reliably be converted to python packages
47
+ without EUPS.
48
+ """
49
+
50
+ quotePaths = False
51
+ _proxy: ResourcePath | None = None
52
+ _default_namespace: str = "lsst"
53
+
54
+ def _set_proxy(self) -> None:
55
+ """Calculate the internal `ResourcePath` corresponding to the public
56
+ version.
57
+ """
58
+ # getPackageDir returns an absolute path.
59
+ try:
60
+ eups_path = getPackageDir(self.netloc)
61
+ log.debug("Found EUPS package %s via env var", self.netloc)
62
+ except LookupError:
63
+ eups_path = ""
64
+ if eups_path:
65
+ # Must convert this path into a file URI.
66
+ new_path = posixpath.join(os2posix(eups_path), os2posix(self.path.lstrip("/")))
67
+ parsed = self._uri._replace(path=urllib.parse.quote(new_path), scheme="file", netloc="")
68
+ self._proxy = ResourcePath(parsed)
69
+ return
70
+
71
+ # If there is no _DIR env var we need to look for python package
72
+ # resource. There is no guaranteed way to generated a python package
73
+ # from an EUPS product name.
74
+ # daf_butler -> lsst.daf.butler
75
+ # image_cutout_backend -> lsst.image_cutout_backend
76
+ # astro_metadata_translator -> astro_metadata_translator
77
+ product = self.netloc
78
+ variants = (
79
+ product,
80
+ self._default_namespace + "." + product.replace("_", "."),
81
+ self._default_namespace + "." + product,
82
+ product.replace("_", "."),
83
+ )
84
+ for variant in variants:
85
+ proxy = ResourcePath(f"resource://{variant}/resources", forceDirectory=True)
86
+ # This can be slow because package imports happen but there is
87
+ # no other way to check that we have the correct variant.
88
+ log.debug("Trying variant %s", proxy)
89
+ if proxy.exists():
90
+ self._proxy = proxy
91
+ if self.path:
92
+ self._proxy = self._proxy.join(self.path.lstrip("/"))
93
+ log.debug(f"Found variant {variant}")
94
+ return
95
+
96
+ # Can not find an actively set up package or resources for this EUPS
97
+ # product. Return without setting a proxy to allow standard URI
98
+ # path manipulations to happen but defer failure until someone tries
99
+ # to use the proxy for read.
100
+ log.debug("Could not find any files corresponding to %s", self)
101
+ return
@@ -42,6 +42,8 @@ class PackageResourcePath(ResourcePath):
42
42
  resource name.
43
43
  """
44
44
 
45
+ quotePaths = False
46
+
45
47
  def _get_ref(self) -> resources.abc.Traversable | None:
46
48
  """Obtain the object representing the resource.
47
49
 
@@ -0,0 +1,148 @@
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
+
12
+ from __future__ import annotations
13
+
14
+ __all__ = ("ProxiedResourcePath",)
15
+
16
+ import contextlib
17
+ import logging
18
+ import re
19
+ from abc import ABC, abstractmethod
20
+ from collections.abc import Iterator
21
+
22
+ from ._resourcePath import ResourceHandleProtocol, ResourcePath, ResourcePathExpression
23
+ from .utils import TransactionProtocol
24
+
25
+ try:
26
+ import fsspec
27
+ from fsspec.spec import AbstractFileSystem
28
+ except ImportError:
29
+ fsspec = None
30
+ AbstractFileSystem = type
31
+
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+
36
+ class ProxiedResourcePath(ABC, ResourcePath):
37
+ """URI that is represented internally by another type of URI for file I/O.
38
+
39
+ For example ``abc://xyz/file.txt`` could be the public URI form but
40
+ internally all file access is forwarded to a ``file`` URI.
41
+ """
42
+
43
+ _proxy: ResourcePath | None = None
44
+
45
+ @abstractmethod
46
+ def _set_proxy(self) -> None:
47
+ """Calculate the internal `ResourcePath` corresponding to the public
48
+ version.
49
+ """
50
+ raise NotImplementedError("Proxy must be configured")
51
+
52
+ def _get_proxy(self) -> ResourcePath:
53
+ """Retrieve the proxied ResourcePath."""
54
+ proxy = self._proxy
55
+ if proxy is None:
56
+ raise FileNotFoundError(f"Internal error: No proxy ResourcePath available for {self}")
57
+ return proxy
58
+
59
+ def to_fsspec(self) -> tuple[AbstractFileSystem, str]:
60
+ try:
61
+ proxy = self._get_proxy()
62
+ except FileNotFoundError:
63
+ raise NotImplementedError(f"No proxy registered for {self}. Resource does not exist.") from None
64
+ return proxy.to_fsspec()
65
+
66
+ def isdir(self) -> bool:
67
+ if self.dirLike is None:
68
+ try:
69
+ proxy = self._get_proxy()
70
+ except FileNotFoundError:
71
+ return False
72
+ self.dirLike = proxy.isdir()
73
+ return self.dirLike
74
+
75
+ def exists(self) -> bool:
76
+ try:
77
+ proxy = self._get_proxy()
78
+ except FileNotFoundError:
79
+ # If there is no proxy registered then the resource can not exist.
80
+ return False
81
+ return proxy.exists()
82
+
83
+ def remove(self) -> None:
84
+ proxy = self._get_proxy()
85
+ proxy.remove()
86
+
87
+ def read(self, size: int = -1) -> bytes:
88
+ proxy = self._get_proxy()
89
+ return proxy.read(size=size)
90
+
91
+ @contextlib.contextmanager
92
+ def as_local(
93
+ self, multithreaded: bool = True, tmpdir: ResourcePathExpression | None = None
94
+ ) -> Iterator[ResourcePath]:
95
+ proxy = self._get_proxy()
96
+ with proxy.as_local(multithreaded=multithreaded, tmpdir=tmpdir) as loc:
97
+ yield loc
98
+
99
+ @contextlib.contextmanager
100
+ def open(
101
+ self,
102
+ mode: str = "r",
103
+ *,
104
+ encoding: str | None = None,
105
+ prefer_file_temporary: bool = False,
106
+ ) -> Iterator[ResourceHandleProtocol]:
107
+ proxy = self._get_proxy()
108
+ with proxy.open(mode, encoding=encoding, prefer_file_temporary=prefer_file_temporary) as fh:
109
+ yield fh
110
+
111
+ def walk(
112
+ self, file_filter: str | re.Pattern | None = None
113
+ ) -> Iterator[list | tuple[ResourcePath, list[str], list[str]]]:
114
+ try:
115
+ proxy = self._get_proxy()
116
+ except FileNotFoundError as e:
117
+ raise ValueError(str(e)) from None
118
+ for proxied_root, dirs, files in proxy.walk(file_filter=file_filter):
119
+ # Need to return the directory in the original form and not the
120
+ # proxy form.
121
+ relative_to_self = proxied_root.path.removeprefix(proxy.path)
122
+ root = self.replace(path=self._pathModule.join(self.path, relative_to_self))
123
+ yield root, dirs, files
124
+
125
+ def size(self) -> int:
126
+ proxy = self._get_proxy()
127
+ return proxy.size()
128
+
129
+ def write(self, data: bytes, overwrite: bool = True) -> None:
130
+ proxy = self._get_proxy()
131
+ proxy.write(data, overwrite=overwrite)
132
+
133
+ def mkdir(self) -> None:
134
+ proxy = self._get_proxy()
135
+ proxy.mkdir()
136
+
137
+ def transfer_from(
138
+ self,
139
+ src: ResourcePath,
140
+ transfer: str = "copy",
141
+ overwrite: bool = False,
142
+ transaction: TransactionProtocol | None = None,
143
+ multithreaded: bool = True,
144
+ ) -> None:
145
+ proxy = self._get_proxy()
146
+ proxy.transfer_from(
147
+ src, transfer=transfer, overwrite=overwrite, transaction=transaction, multithreaded=multithreaded
148
+ )
@@ -16,6 +16,7 @@ __all__ = ("SchemelessResourcePath",)
16
16
  import logging
17
17
  import os
18
18
  import os.path
19
+ import re
19
20
  import stat
20
21
  import urllib.parse
21
22
  from pathlib import PurePath
@@ -211,12 +212,23 @@ class SchemelessResourcePath(FileResourcePath):
211
212
  expandedPath = os.path.expanduser(urllib.parse.unquote(parsed.path))
212
213
 
213
214
  # We might also be receiving a path containing environment variables
214
- # so expand those here
215
+ # so expand those here, although we treat $X_DIR at the start of the
216
+ # path as a special EUPS URI. This allows us to handle EUPS-style
217
+ # env var specifications even if EUPS has not set them.
218
+ # Support $X_DIR and ${X_DIR} variants at the start of the path.
219
+ if eups := re.match(r"(\$\{?([A-Z_]+)_DIR\}?)/", expandedPath):
220
+ replacements["scheme"] = "eups"
221
+ # Two matching groups: the entire env var, and the EUPS product.
222
+ replacements["netloc"] = eups.group(2).lower()
223
+ expandedPath = expandedPath.removeprefix(eups.group(1))
224
+
215
225
  expandedPath = os.path.expandvars(expandedPath)
216
226
 
217
- # Ensure that this becomes a file URI if it is already absolute
227
+ # Ensure that this becomes a file URI if it is already absolute, unless
228
+ # we already overrode it above.
218
229
  if os.path.isabs(expandedPath):
219
- replacements["scheme"] = "file"
230
+ if "scheme" not in replacements:
231
+ replacements["scheme"] = "file"
220
232
  # Keep in OS form for now to simplify later logic
221
233
  replacements["path"] = os.path.normpath(expandedPath)
222
234
  elif forceAbsolute:
@@ -265,9 +277,9 @@ class SchemelessResourcePath(FileResourcePath):
265
277
  if not replacements["path"].endswith(os.sep):
266
278
  replacements["path"] += os.sep
267
279
 
268
- if "scheme" in replacements:
280
+ if "scheme" in replacements and replacements["scheme"] == "file":
269
281
  # This is now meant to be a URI path so force to posix
270
- # and quote
282
+ # and quote. EUPS URIs are not quoted.
271
283
  replacements["path"] = urllib.parse.quote(os2posix(replacements["path"]))
272
284
 
273
285
  # ParseResult is a NamedTuple so _replace is standard API
@@ -417,11 +417,19 @@ class GenericTestCase(_GenericTestCase):
417
417
  def test_escapes(self) -> None:
418
418
  """Special characters in file paths."""
419
419
  src = self.root_uri.join("bbb/???/test.txt")
420
- self.assertNotIn("???", src.path)
420
+ quotes = src.quotePaths
421
+
422
+ if quotes:
423
+ self.assertNotIn("???", src.path)
424
+ else:
425
+ self.assertIn("???", src.path)
421
426
  self.assertIn("???", src.unquoted_path)
422
427
 
423
428
  file = src.updatedFile("tests??.txt")
424
- self.assertNotIn("??.txt", file.path)
429
+ if quotes:
430
+ self.assertNotIn("??.txt", file.path)
431
+ else:
432
+ self.assertIn("??.txt", file.path)
425
433
 
426
434
  src = src.updatedFile("tests??.txt")
427
435
  self.assertIn("??.txt", src.unquoted_path)
@@ -456,7 +464,10 @@ class GenericTestCase(_GenericTestCase):
456
464
 
457
465
  fnew2 = fdir.join(new2name)
458
466
  self.assertTrue(fnew2.unquoted_path.endswith(new2name))
459
- self.assertNotIn("###", fnew2.path)
467
+ if quotes:
468
+ self.assertNotIn("###", fnew2.path)
469
+ else:
470
+ self.assertIn("###", fnew2.path)
460
471
 
461
472
  # Test that children relative to schemeless and file schemes
462
473
  # still return the same unquoted name
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "30.0.0rc3"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-resources
3
- Version: 29.2025.4800
3
+ Version: 30.0.0rc3
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-Expression: BSD-3-Clause
@@ -14,12 +14,14 @@ python/lsst/resources/__init__.py
14
14
  python/lsst/resources/_resourcePath.py
15
15
  python/lsst/resources/dav.py
16
16
  python/lsst/resources/davutils.py
17
+ python/lsst/resources/eups.py
17
18
  python/lsst/resources/file.py
18
19
  python/lsst/resources/gs.py
19
20
  python/lsst/resources/http.py
20
21
  python/lsst/resources/location.py
21
22
  python/lsst/resources/mem.py
22
23
  python/lsst/resources/packageresource.py
24
+ python/lsst/resources/proxied.py
23
25
  python/lsst/resources/py.typed
24
26
  python/lsst/resources/s3.py
25
27
  python/lsst/resources/s3utils.py
@@ -40,6 +42,7 @@ python/lsst_resources.egg-info/requires.txt
40
42
  python/lsst_resources.egg-info/top_level.txt
41
43
  python/lsst_resources.egg-info/zip-safe
42
44
  tests/test_dav.py
45
+ tests/test_eups.py
43
46
  tests/test_file.py
44
47
  tests/test_gs.py
45
48
  tests/test_http.py
@@ -0,0 +1,239 @@
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
+
12
+ import os
13
+ import re
14
+ import sys
15
+ import unittest
16
+ import unittest.mock
17
+
18
+ from lsst.resources import ResourcePath
19
+ from lsst.resources.eups import EupsResourcePath
20
+ from lsst.resources.tests import GenericTestCase
21
+
22
+ TESTDIR = os.path.abspath(os.path.dirname(__file__))
23
+ PKG_DIR = os.path.join(TESTDIR, "packages", "eups")
24
+ PACKAGE_PATH = os.path.join(TESTDIR, "packages")
25
+
26
+
27
+ class EupsTestCase(GenericTestCase, unittest.TestCase):
28
+ """Generic test of resource URIs."""
29
+
30
+ scheme = "eups"
31
+ netloc = "pkg"
32
+
33
+ @classmethod
34
+ def setUpClass(cls) -> None:
35
+ # The actual value does not matter for these tests.
36
+ os.environ["PKG_DIR"] = PKG_DIR
37
+ super().setUpClass()
38
+
39
+ @classmethod
40
+ def tearDownClass(cls) -> None:
41
+ del os.environ["PKG_DIR"]
42
+ super().tearDownClass()
43
+
44
+ def test_relative(self) -> None:
45
+ # This test uses two additional netlocs which need corresponding
46
+ # environment variables to function. The values do not matter.
47
+ with unittest.mock.patch.dict(os.environ, {"OTHER_DIR": "x", "MY.HOST_DIR": "y"}):
48
+ super().test_relative()
49
+
50
+
51
+ class EupsReadTestCase(unittest.TestCase):
52
+ """Test that EUPS information can be read.
53
+
54
+ EUPS resources can be thought of as being read only even if the
55
+ underlying URI is a ``file`` URI.
56
+ """
57
+
58
+ scheme = "eups"
59
+ netloc = "pkg"
60
+
61
+ @classmethod
62
+ def setUpClass(cls) -> None:
63
+ # The actual value does not matter for these tests.
64
+ os.environ["PKG_DIR"] = PKG_DIR
65
+ super().setUpClass()
66
+
67
+ @classmethod
68
+ def tearDownClass(cls) -> None:
69
+ del os.environ["PKG_DIR"]
70
+ super().tearDownClass()
71
+
72
+ def setUp(self):
73
+ self.root = f"{self.scheme}://{self.netloc}"
74
+ self.root_uri = ResourcePath(self.root)
75
+
76
+ def test_read(self):
77
+ uri = self.root_uri.join("config/test.txt")
78
+ self.assertTrue(uri.exists(), f"Check {uri} exists")
79
+
80
+ content = uri.read().decode()
81
+ self.assertIn("A test config.", content)
82
+
83
+ with uri.as_local() as local_uri:
84
+ self.assertEqual(local_uri.scheme, "file")
85
+ self.assertTrue(local_uri.exists())
86
+
87
+ truncated = uri.read(size=9).decode()
88
+ self.assertEqual(truncated, content[:9])
89
+
90
+ # Check that directory determination can work directly without the
91
+ # trailing slash.
92
+ d = self.root_uri.join("config")
93
+ self.assertTrue(d.isdir())
94
+ self.assertTrue(d.dirLike)
95
+
96
+ d = self.root_uri.join("config/", forceDirectory=True)
97
+ self.assertTrue(d.exists(), f"Check directory {d} exists")
98
+ self.assertTrue(d.isdir())
99
+
100
+ with self.assertRaises(IsADirectoryError):
101
+ with d.as_local() as local_uri:
102
+ pass
103
+
104
+ j = d.join("test.txt")
105
+ self.assertEqual(uri, j)
106
+ self.assertFalse(j.dirLike)
107
+ self.assertFalse(j.isdir())
108
+ not_there = d.join("not-there.yaml")
109
+ self.assertFalse(not_there.exists())
110
+
111
+ bad = ResourcePath(f"{self.scheme}://bad.module/not.yaml")
112
+ multi = ResourcePath.mexists([uri, bad, not_there])
113
+ self.assertTrue(multi[uri])
114
+ self.assertFalse(multi[bad])
115
+ self.assertFalse(multi[not_there])
116
+
117
+ # Check that the bad URI works as expected.
118
+ self.assertFalse(bad.exists())
119
+ self.assertFalse(bad.isdir())
120
+ with self.assertRaises(FileNotFoundError):
121
+ bad.read()
122
+ with self.assertRaises(FileNotFoundError):
123
+ with bad.as_local():
124
+ pass
125
+ with self.assertRaises(FileNotFoundError):
126
+ with bad.open("r"):
127
+ pass
128
+
129
+ # fsspec is always not implemented.
130
+ with self.assertRaises(NotImplementedError):
131
+ bad.to_fsspec()
132
+
133
+ def test_open(self):
134
+ uri = self.root_uri.join("config/test.txt")
135
+ with uri.open("rb") as buffer:
136
+ content = buffer.read()
137
+ self.assertEqual(uri.read(), content)
138
+
139
+ with uri.open("r") as buffer:
140
+ content = buffer.read()
141
+ self.assertEqual(uri.read().decode(), content)
142
+
143
+ def test_walk(self):
144
+ """Test that we can find file resources.
145
+
146
+ Try to find resources in this package. Python does not care whether
147
+ a resource is a Python file or anything else.
148
+ """
149
+ resource = ResourcePath(f"{self.scheme}://{self.netloc}/")
150
+ resources = set(ResourcePath.findFileResources([resource]))
151
+
152
+ # Do not try to list all possible options. Files can move around
153
+ # and cache files can appear.
154
+ subset = {
155
+ ResourcePath(f"{self.scheme}://{self.netloc}/config/test.txt"),
156
+ ResourcePath(f"{self.scheme}://{self.netloc}/config/test2.yaml"),
157
+ }
158
+ for r in subset:
159
+ self.assertIn(r, resources)
160
+
161
+ resources = set(
162
+ ResourcePath.findFileResources(
163
+ [ResourcePath(f"{self.scheme}://{self.netloc}/")], file_filter=r".*\.json"
164
+ )
165
+ )
166
+ self.assertEqual(resources, set())
167
+
168
+ # Compare regex with str.
169
+ regex = r".*\.yaml"
170
+ y_files_str = list(resource.walk(file_filter=regex))
171
+ y_files_re = list(resource.walk(file_filter=re.compile(regex)))
172
+ self.assertGreater(len(y_files_str), 1)
173
+ self.assertEqual(y_files_str, y_files_re)
174
+
175
+ bad_dir = ResourcePath(f"{self.scheme}://bad.module/a/dir/")
176
+ self.assertTrue(bad_dir.isdir())
177
+ with self.assertRaises(ValueError):
178
+ list(bad_dir.walk())
179
+
180
+ def test_env_var(self):
181
+ """Test that environment variables are converted."""
182
+ with unittest.mock.patch.dict(os.environ, {"MY_TEST_DIR": TESTDIR}):
183
+ for env_string in ("$MY_TEST_DIR", "${MY_TEST_DIR}"):
184
+ uri = ResourcePath(f"{env_string}/data/dir1/a.yaml")
185
+ self.assertEqual(uri.path, "/data/dir1/a.yaml")
186
+ self.assertEqual(uri.scheme, "eups")
187
+ self.assertEqual(uri.netloc, "my_test")
188
+ self.assertTrue(uri.exists())
189
+
190
+
191
+ class EupsAsResourcesReadTestCase(EupsReadTestCase):
192
+ """Test that EUPS information can be read via resources.
193
+
194
+ EUPS resources can be thought of as being read only even if the
195
+ underlying URI is a ``file`` URI.
196
+ """
197
+
198
+ scheme = "eups"
199
+ netloc = "pkg1"
200
+
201
+ @classmethod
202
+ def setUpClass(cls) -> None:
203
+ # The actual value does not matter for these tests.
204
+ sys.path.append(PACKAGE_PATH)
205
+ super().setUpClass()
206
+
207
+ @classmethod
208
+ def tearDownClass(cls) -> None:
209
+ sys.path.remove(PACKAGE_PATH)
210
+ super().tearDownClass()
211
+
212
+
213
+ class EupsAsResourcesReadTestCase2(EupsAsResourcesReadTestCase):
214
+ """Test that EUPS information can be read via resources with lsst-style
215
+ package.
216
+
217
+ EUPS resources can be thought of as being read only even if the
218
+ underlying URI is a ``file`` URI.
219
+ """
220
+
221
+ scheme = "eups"
222
+ netloc = "pkg2_sub"
223
+
224
+ @classmethod
225
+ def setUpClass(cls) -> None:
226
+ # To avoid confusion with other lsst packages, override the default
227
+ # prefix that is added to the EUPS name.
228
+ cls.prefix = EupsResourcePath._default_namespace
229
+ EupsResourcePath._default_namespace = "prefix"
230
+ super().setUpClass()
231
+
232
+ @classmethod
233
+ def tearDownClass(cls) -> None:
234
+ EupsResourcePath._default_namespace = cls.prefix
235
+ super().tearDownClass()
236
+
237
+
238
+ if __name__ == "__main__":
239
+ unittest.main()
@@ -46,14 +46,14 @@ class FileTestCase(GenericTestCase, unittest.TestCase):
46
46
 
47
47
  def test_env_var(self):
48
48
  """Test that environment variables are expanded."""
49
- with unittest.mock.patch.dict(os.environ, {"MY_TEST_DIR": "/a/b/c"}):
50
- uri = ResourcePath("${MY_TEST_DIR}/d.txt")
49
+ with unittest.mock.patch.dict(os.environ, {"MY_TEST_DIRX": "/a/b/c"}):
50
+ uri = ResourcePath("${MY_TEST_DIRX}/d.txt")
51
51
  self.assertEqual(uri.path, "/a/b/c/d.txt")
52
52
  self.assertEqual(uri.scheme, "file")
53
53
 
54
54
  # This will not expand
55
- uri = ResourcePath("${MY_TEST_DIR}/d.txt", forceAbsolute=False)
56
- self.assertEqual(uri.path, "${MY_TEST_DIR}/d.txt")
55
+ uri = ResourcePath("${MY_TEST_DIRX}/d.txt", forceAbsolute=False)
56
+ self.assertEqual(uri.path, "${MY_TEST_DIRX}/d.txt")
57
57
  self.assertFalse(uri.scheme)
58
58
 
59
59
  def test_ospath(self):
@@ -224,6 +224,9 @@ class S3ReadWriteTestCaseBase(GenericReadWriteTestCase):
224
224
  def test_implicit_default_threading(self):
225
225
  S3ResourcePath.use_threads = None
226
226
  boto_default = signature(boto3.s3.transfer.TransferConfig).parameters["use_threads"].default
227
+ # Newer versions of boto return None as the default.
228
+ if boto_default is None:
229
+ boto_default = True
227
230
  test_resource_path = self.root_uri.join("test_file.dat")
228
231
  self.assertEqual(test_resource_path._transfer_config.use_threads, boto_default)
229
232
 
@@ -231,6 +234,9 @@ class S3ReadWriteTestCaseBase(GenericReadWriteTestCase):
231
234
  with mock.patch.dict(os.environ, {"LSST_S3_USE_THREADS": "None"}):
232
235
  S3ResourcePath.use_threads = None
233
236
  boto_default = signature(boto3.s3.transfer.TransferConfig).parameters["use_threads"].default
237
+ # Newer versions of boto return None as the default.
238
+ if boto_default is None:
239
+ boto_default = True
234
240
  test_resource_path = self.root_uri.join("test_file.dat")
235
241
  self.assertEqual(test_resource_path._transfer_config.use_threads, boto_default)
236
242
 
@@ -1,2 +0,0 @@
1
- __all__ = ["__version__"]
2
- __version__ = "29.2025.4800"