Coverage for python / lsst / resources / packageresource.py: 0%
89 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-30 08:38 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-30 08:38 +0000
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.
12from __future__ import annotations
14__all__ = ("PackageResourcePath",)
16import contextlib
17import logging
18import re
19from collections.abc import Iterator
20from importlib import resources
21from typing import TYPE_CHECKING
23if TYPE_CHECKING:
24 try:
25 import fsspec
26 from fsspec.spec import AbstractFileSystem
27 except ImportError:
28 fsspec = None
29 AbstractFileSystem = type
31from ._resourceHandles._baseResourceHandle import ResourceHandleProtocol
32from ._resourcePath import ResourceInfo, ResourcePath, ResourcePathExpression
33from .file import _path_to_info
35log = logging.getLogger(__name__)
38class PackageResourcePath(ResourcePath):
39 """URI referring to a Python package resource.
41 These URIs look like: ``resource://lsst.daf.butler/configs/file.yaml``
42 where the network location is the Python package and the path is the
43 resource name.
44 """
46 quotePaths = False
48 def _get_ref(self) -> resources.abc.Traversable | None:
49 """Obtain the object representing the resource.
51 Returns
52 -------
53 path : `resources.abc.Traversable` or `None`
54 The reference to the resource path, or `None` if the module
55 associated with the resources is not accessible. This can happen
56 if Python can't import the Python package defining the resource.
57 """
58 # Need the path without the leading /.
59 path = self.path.lstrip("/")
60 try:
61 ref = resources.files(self.netloc).joinpath(path)
62 except ModuleNotFoundError:
63 return None
64 return ref
66 def isdir(self) -> bool:
67 """Return True if this URI is a directory, else False."""
68 if self.dirLike is None:
69 ref = self._get_ref()
70 if ref is not None:
71 self.dirLike = ref.is_dir()
72 else:
73 return False
74 return self.dirLike
76 def exists(self) -> bool:
77 """Check that the python resource exists."""
78 ref = self._get_ref()
79 if ref is None:
80 return False
81 return ref.is_file() or ref.is_dir()
83 def get_info(self) -> ResourceInfo:
84 """Return metadata about the resource without reading its contents."""
85 ref = self._get_ref()
86 if ref is None or not (ref.is_file() or ref.is_dir()):
87 raise FileNotFoundError(f"Unable to locate resource {self}.")
89 info = _path_to_info(str(self), ref)
91 if info is None:
92 # Edge case such as file in Zip.
93 return ResourceInfo(
94 uri=str(self),
95 is_file=True,
96 size=0,
97 last_modified=None,
98 checksums={},
99 )
100 return info
102 def read(self, size: int = -1) -> bytes:
103 ref = self._get_ref()
104 if not ref:
105 raise FileNotFoundError(f"Unable to locate resource {self}.")
106 with ref.open("rb") as fh:
107 return fh.read(size)
109 @contextlib.contextmanager
110 def as_local(
111 self, multithreaded: bool = True, tmpdir: ResourcePathExpression | None = None
112 ) -> Iterator[ResourcePath]:
113 """Return the location of the Python resource as local file.
115 Parameters
116 ----------
117 multithreaded : `bool`, optional
118 Unused.
119 tmpdir : `ResourcePathExpression` or `None`, optional
120 Unused.
122 Yields
123 ------
124 local : `ResourcePath`
125 This might be the original resource or a copy on the local file
126 system.
127 multithreaded : `bool`, optional
128 Unused.
130 Notes
131 -----
132 The context manager will automatically delete any local temporary
133 file.
135 Examples
136 --------
137 Should be used as a context manager:
139 .. code-block:: py
141 with uri.as_local() as local:
142 ospath = local.ospath
143 """
144 ref = self._get_ref()
145 if ref is None:
146 raise FileNotFoundError(f"Resource {self} could not be located.")
147 if ref.is_dir():
148 raise IsADirectoryError(f"Directory-like URI {self} cannot be fetched as local.")
150 with resources.as_file(ref) as file:
151 yield ResourcePath(file)
153 @contextlib.contextmanager
154 def open(
155 self,
156 mode: str = "r",
157 *,
158 encoding: str | None = None,
159 prefer_file_temporary: bool = False,
160 ) -> Iterator[ResourceHandleProtocol]:
161 # Docstring inherited.
162 if "r" not in mode or "+" in mode:
163 raise RuntimeError(f"Package resource URI {self} is read-only.")
164 ref = self._get_ref()
165 if ref is None:
166 raise FileNotFoundError(f"Could not open resource {self}.")
167 # mypy uses the literal value of mode to work out the parameters
168 # and return value but mode here is a variable.
169 with ref.open(mode, encoding=encoding) as buffer: # type: ignore[call-overload]
170 yield buffer
172 def walk(
173 self, file_filter: str | re.Pattern | None = None
174 ) -> Iterator[list | tuple[ResourcePath, list[str], list[str]]]:
175 # Docstring inherited.
176 if not self.isdir():
177 raise ValueError(f"Can not walk a non-directory URI: {self}")
179 if isinstance(file_filter, str):
180 file_filter = re.compile(file_filter)
182 ref = self._get_ref()
183 if ref is None:
184 raise ValueError(f"Unable to find resource {self}.")
186 files: list[str] = []
187 dirs: list[str] = []
188 for item in ref.iterdir():
189 if item.is_dir():
190 dirs.append(item.name)
191 elif item.is_file():
192 files.append(item.name)
193 # If the item wasn't covered by one of the cases above that
194 # means it was deleted concurrently with this walk or is
195 # not a plain file/directory/symlink
197 if file_filter is not None:
198 files = [f for f in files if file_filter.search(f)]
200 if not dirs and not files:
201 return
202 else:
203 yield type(self)(self, forceAbsolute=False, forceDirectory=True), dirs, files
205 for dir in dirs:
206 new_uri = self.join(dir, forceDirectory=True)
207 yield from new_uri.walk(file_filter)
209 def to_fsspec(self) -> tuple[AbstractFileSystem, str]:
210 """Return an abstract file system and path that can be used by fsspec.
212 Python package resources are effectively local files in most cases
213 but can be found inside ZIP files. To support this we would have
214 to change this API to a context manager (using
215 ``importlib.resources.as_file``) or find an API where fsspec knows
216 about python package resource.
218 Returns
219 -------
220 fs : `fsspec.spec.AbstractFileSystem`
221 A file system object suitable for use with the returned path.
222 path : `str`
223 A path that can be opened by the file system object.
224 """
225 raise NotImplementedError("fsspec can not be used with python package resources.")