Coverage for python/lsst/resources/packageresource.py: 98%

90 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-19 11:17 +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. 

11 

12from __future__ import annotations 

13 

14__all__ = ("PackageResourcePath",) 

15 

16import contextlib 

17import logging 

18import re 

19import sys 

20 

21if sys.version_info < (3, 11, 0): 21 ↛ 26line 21 didn't jump to line 26, because the condition on line 21 was never true

22 # Mypy will try to use the first import it encounters and ignores 

23 # the sys.version_info. This means that the first import has to be 

24 # the backwards compatibility import since we are currently using 3.10 

25 # for mypy. Once we switch to 3.11 for mypy the order will have to change. 

26 import importlib_resources as resources 

27else: 

28 from importlib import resources # type: ignore[no-redef] 

29 

30from collections.abc import Iterator 

31from typing import TYPE_CHECKING 

32 

33from ._resourceHandles._baseResourceHandle import ResourceHandleProtocol 

34from ._resourcePath import ResourcePath 

35 

36if TYPE_CHECKING: 

37 import urllib.parse 

38 

39log = logging.getLogger(__name__) 

40 

41 

42class PackageResourcePath(ResourcePath): 

43 """URI referring to a Python package resource. 

44 

45 These URIs look like: ``resource://lsst.daf.butler/configs/file.yaml`` 

46 where the network location is the Python package and the path is the 

47 resource name. 

48 """ 

49 

50 @classmethod 

51 def _fixDirectorySep( 

52 cls, parsed: urllib.parse.ParseResult, forceDirectory: bool = False 

53 ) -> tuple[urllib.parse.ParseResult, bool]: 

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

55 parsed, dirLike = super()._fixDirectorySep(parsed, forceDirectory=forceDirectory) 

56 if not dirLike: 

57 try: 

58 # If the resource location does not exist this can 

59 # fail immediately. It is possible we are doing path 

60 # manipulation and not wanting to read the resource now, 

61 # so catch the error and move on. 

62 ref = resources.files(parsed.netloc).joinpath(parsed.path.lstrip("/")) 

63 except ModuleNotFoundError: 

64 pass 

65 else: 

66 dirLike = ref.is_dir() 

67 return parsed, dirLike 

68 

69 def _get_ref(self) -> resources.abc.Traversable | None: 

70 """Obtain the object representing the resource. 

71 

72 Returns 

73 ------- 

74 path : `resources.abc.Traversable` or `None` 

75 The reference to the resource path, or `None` if the module 

76 associated with the resources is not accessible. This can happen 

77 if Python can't import the Python package defining the resource. 

78 """ 

79 try: 

80 ref = resources.files(self.netloc).joinpath(self.relativeToPathRoot) 

81 except ModuleNotFoundError: 

82 return None 

83 return ref 

84 

85 def isdir(self) -> bool: 

86 """Return True if this URI is a directory, else False.""" 

87 if self.dirLike: # Always bypass if we guessed the resource is a directory. 

88 return True 

89 ref = self._get_ref() 

90 if ref is None: 

91 return False # Does not seem to exist so assume not a directory. 

92 return ref.is_dir() 

93 

94 def exists(self) -> bool: 

95 """Check that the python resource exists.""" 

96 ref = self._get_ref() 

97 if ref is None: 

98 return False 

99 return ref.is_file() or ref.is_dir() 

100 

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

102 ref = self._get_ref() 

103 if not ref: 

104 raise FileNotFoundError(f"Unable to locate resource {self}.") 

105 with ref.open("rb") as fh: 

106 return fh.read(size) 

107 

108 @contextlib.contextmanager 

109 def as_local(self) -> Iterator[ResourcePath]: 

110 """Return the location of the Python resource as local file. 

111 

112 Yields 

113 ------ 

114 local : `ResourcePath` 

115 This might be the original resource or a copy on the local file 

116 system. 

117 

118 Notes 

119 ----- 

120 The context manager will automatically delete any local temporary 

121 file. 

122 

123 Examples 

124 -------- 

125 Should be used as a context manager: 

126 

127 .. code-block:: py 

128 

129 with uri.as_local() as local: 

130 ospath = local.ospath 

131 """ 

132 ref = self._get_ref() 

133 if ref is None: 

134 raise FileNotFoundError(f"Resource {self} could not be located.") 

135 if ref.is_dir(): 

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

137 

138 with resources.as_file(ref) as file: 

139 yield ResourcePath(file) 

140 

141 @contextlib.contextmanager 

142 def open( 

143 self, 

144 mode: str = "r", 

145 *, 

146 encoding: str | None = None, 

147 prefer_file_temporary: bool = False, 

148 ) -> Iterator[ResourceHandleProtocol]: 

149 # Docstring inherited. 

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

151 raise RuntimeError(f"Package resource URI {self} is read-only.") 

152 ref = self._get_ref() 

153 if ref is None: 

154 raise FileNotFoundError(f"Could not open resource {self}.") 

155 with ref.open(mode, encoding=encoding) as buffer: 

156 yield buffer 

157 

158 def walk( 

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

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

161 # Docstring inherited. 

162 if not self.isdir(): 

163 raise ValueError(f"Can not walk a non-directory URI: {self}") 

164 

165 if isinstance(file_filter, str): 

166 file_filter = re.compile(file_filter) 

167 

168 ref = self._get_ref() 

169 if ref is None: 

170 raise ValueError(f"Unable to find resource {self}.") 

171 

172 files: list[str] = [] 

173 dirs: list[str] = [] 

174 for item in ref.iterdir(): 

175 if item.is_dir(): 

176 dirs.append(item.name) 

177 elif item.is_file(): 177 ↛ 174line 177 didn't jump to line 174, because the condition on line 177 was never false

178 files.append(item.name) 

179 # If the item wasn't covered by one of the cases above that 

180 # means it was deleted concurrently with this walk or is 

181 # not a plain file/directory/symlink 

182 

183 if file_filter is not None: 

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

185 

186 if not dirs and not files: 

187 return 

188 else: 

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

190 

191 for dir in dirs: 

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

193 yield from new_uri.walk(file_filter)