Coverage for python/lsst/resources/schemeless.py: 94%

74 statements  

« prev     ^ index     » next       coverage.py v7.4.3, created at 2024-03-13 09:59 +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__ = ("SchemelessResourcePath",) 

15 

16import logging 

17import os 

18import os.path 

19import stat 

20import urllib.parse 

21from pathlib import PurePath 

22 

23from ._resourcePath import ResourcePath 

24from .file import FileResourcePath 

25from .utils import os2posix 

26 

27log = logging.getLogger(__name__) 

28 

29 

30class SchemelessResourcePath(FileResourcePath): 

31 """Scheme-less URI referring to the local file system or relative URI.""" 

32 

33 _pathLib = PurePath 

34 _pathModule = os.path 

35 quotePaths = False 

36 

37 @property 

38 def ospath(self) -> str: 

39 """Path component of the URI localized to current OS.""" 

40 return self.path 

41 

42 def isabs(self) -> bool: 

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

44 

45 For non-schemeless URIs this is always true. 

46 

47 Returns 

48 ------- 

49 isabs : `bool` 

50 `True` if the file is absolute, `False` otherwise. Will always 

51 be `False` for schemeless URIs. 

52 """ 

53 return False 

54 

55 def abspath(self) -> ResourcePath: 

56 """Force a schemeless URI to a file URI. 

57 

58 This will include URI quoting of the path. 

59 

60 Returns 

61 ------- 

62 file : `FileResourcePath` 

63 A new URI using file scheme. 

64 

65 Notes 

66 ----- 

67 The current working directory will be used to convert this scheme-less 

68 URI to an absolute path. 

69 """ 

70 # Convert this URI to a string so that any fragments will be 

71 # processed correctly by the ResourcePath constructor. We provide 

72 # the options that will force the code below in _fixupPathUri to 

73 # return a file URI from a scheme-less one. 

74 return ResourcePath( 

75 str(self), forceAbsolute=True, forceDirectory=self.dirLike, isTemporary=self.isTemporary 

76 ) 

77 

78 def isdir(self) -> bool: 

79 """Return whether this URI is a directory. 

80 

81 Returns 

82 ------- 

83 isdir : `bool` 

84 `True` if this URI is a directory or looks like a directory, 

85 else `False`. 

86 

87 Notes 

88 ----- 

89 If the URI is not known to refer to a file or a directory the file 

90 system will be checked. The relative path will be resolved using 

91 the current working directory. If the path can not be found, `False` 

92 will be returned (matching `os.path.isdir` semantics) but the result 

93 will not be stored in ``dirLike`` and will be checked again on request 

94 in case the working directory has been updated. 

95 """ 

96 if self.dirLike is None: 

97 try: 

98 status = os.stat(self.ospath) 

99 except FileNotFoundError: 

100 # Do not update dirLike flag. 

101 return False 

102 

103 # Do not cache. We do not know if this really refers to a file or 

104 # not and changing directory might change the answer. 

105 return stat.S_ISDIR(status.st_mode) 

106 return self.dirLike 

107 

108 def relative_to(self, other: ResourcePath) -> str | None: 

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

110 

111 Parameters 

112 ---------- 

113 other : `ResourcePath` 

114 URI to use to calculate the relative path. 

115 

116 Returns 

117 ------- 

118 subpath : `str` 

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

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

121 If this URI is a relative URI but the other is 

122 absolute, it is assumed to be in the parent completely unless it 

123 starts with ".." (in which case the path is combined and tested). 

124 If both URIs are relative, the relative paths are compared 

125 for commonality. 

126 

127 Notes 

128 ----- 

129 By definition a relative path will be relative to the enclosing 

130 absolute parent URI. It will be returned unchanged if it does not 

131 use a parent directory specification. 

132 """ 

133 # In some scenarios below a new derived child URI needs to be created 

134 # to convert from scheme-less to absolute URI. 

135 child = None 

136 

137 if not other.isabs(): 

138 # Both are schemeless relative. Use parent implementation 

139 # rather than trying to convert both to file: first since schemes 

140 # match. 

141 pass 

142 elif other.isabs(): 142 ↛ 146line 142 didn't jump to line 146, because the condition on line 142 was never false

143 # Append child to other. This can account for .. in child path. 

144 child = other.join(self.path) 

145 else: 

146 raise RuntimeError(f"Unexpected combination of {child}.relative_to({other}).") 

147 

148 if child is None: 

149 return super().relative_to(other) 

150 return child.relative_to(other) 

151 

152 @classmethod 

153 def _fixupPathUri( 

154 cls, 

155 parsed: urllib.parse.ParseResult, 

156 root: ResourcePath | None = None, 

157 forceAbsolute: bool = False, 

158 forceDirectory: bool | None = None, 

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

160 """Fix up relative paths for local file system. 

161 

162 Parameters 

163 ---------- 

164 parsed : `~urllib.parse.ParseResult` 

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

166 root : `ResourcePath`, optional 

167 Path to use as root when converting relative to absolute. 

168 If `None`, it will be the current working directory. Will be 

169 ignored if the supplied path is already absolute or if 

170 ``forceAbsolute`` is `False`. 

171 forceAbsolute : `bool`, optional 

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

173 absolute path using a ``file`` scheme. If `False` scheme-less URI 

174 will remain scheme-less and will not be updated to ``file`` or 

175 absolute path. 

176 forceDirectory : `bool`, optional 

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

178 URI is interpreted as is. `False` can be used to indicate that 

179 the URI is known to correspond to a file. `None` means that the 

180 status is unknown. 

181 

182 Returns 

183 ------- 

184 modified : `~urllib.parse.ParseResult` 

185 Update result if a URI is being handled. 

186 dirLike : `bool` 

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

188 forceDirectory is True. Otherwise `False`. 

189 

190 Notes 

191 ----- 

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

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

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

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

196 

197 Scheme-less paths are normalized and environment variables are 

198 expanded. 

199 """ 

200 # assume we are not dealing with a directory URI 

201 dirLike = forceDirectory 

202 

203 # Replacement values for the URI 

204 replacements = {} 

205 

206 # this is a local OS file path which can support tilde expansion. 

207 # we quoted it in the constructor so unquote here 

208 expandedPath = os.path.expanduser(urllib.parse.unquote(parsed.path)) 

209 

210 # We might also be receiving a path containing environment variables 

211 # so expand those here 

212 expandedPath = os.path.expandvars(expandedPath) 

213 

214 # Ensure that this becomes a file URI if it is already absolute 

215 if os.path.isabs(expandedPath): 

216 replacements["scheme"] = "file" 

217 # Keep in OS form for now to simplify later logic 

218 replacements["path"] = os.path.normpath(expandedPath) 

219 elif forceAbsolute: 

220 # Need to know the root that should be prepended. 

221 if root is None: 

222 root_str = os.path.abspath(os.path.curdir) 

223 else: 

224 if root.scheme and root.scheme != "file": 224 ↛ 225line 224 didn't jump to line 225, because the condition on line 224 was never true

225 raise ValueError(f"The override root must be a file URI not {root.scheme}") 

226 # os.path does not care whether something is dirLike or not 

227 # so we trust the user. 

228 root_str = os.path.abspath(root.ospath) 

229 

230 # Convert to "file" scheme to make it consistent with the above 

231 # decision. It makes no sense for sometimes an absolute path 

232 # to be a file URI and sometimes for it not to be. 

233 replacements["scheme"] = "file" 

234 

235 # Keep in OS form for now. 

236 replacements["path"] = os.path.normpath(os.path.join(root_str, expandedPath)) 

237 else: 

238 # No change needed for relative local path staying relative 

239 # except normalization 

240 replacements["path"] = os.path.normpath(expandedPath) 

241 # normalization of empty path returns "." so we are dirLike 

242 if expandedPath == "": 

243 dirLike = True 

244 

245 # normpath strips trailing "/" which makes it hard to keep 

246 # track of directory vs file when calling replaceFile 

247 

248 # add the trailing separator only if explicitly required or 

249 # if it was stripped by normpath. Acknowledge that trailing 

250 # separator exists. 

251 endsOnSep = expandedPath.endswith(os.sep) and not replacements["path"].endswith(os.sep) 

252 

253 # Consistency check. 

254 if forceDirectory is False and endsOnSep: 

255 raise ValueError( 

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

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

258 ) 

259 

260 if forceDirectory or endsOnSep or dirLike: 

261 dirLike = True 

262 if not replacements["path"].endswith(os.sep): 

263 replacements["path"] += os.sep 

264 

265 if "scheme" in replacements: 

266 # This is now meant to be a URI path so force to posix 

267 # and quote 

268 replacements["path"] = urllib.parse.quote(os2posix(replacements["path"])) 

269 

270 # ParseResult is a NamedTuple so _replace is standard API 

271 parsed = parsed._replace(**replacements) 

272 

273 # We do allow fragment but do not expect params or query to be 

274 # specified for schemeless 

275 if parsed.params or parsed.query: 275 ↛ 276line 275 didn't jump to line 276, because the condition on line 275 was never true

276 log.warning("Additional items unexpectedly encountered in schemeless URI: %s", parsed.geturl()) 

277 

278 return parsed, dirLike