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

74 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-24 01:19 -0800

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, walk_up: bool = False) -> 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 walk_up : `bool`, optional 

116 Control whether "``..``" can be used to resolve a relative path. 

117 Default is `False`. Can not be `True` on Python version 3.11. 

118 

119 Returns 

120 ------- 

121 subpath : `str` 

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

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

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

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

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

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

128 for commonality. 

129 

130 Notes 

131 ----- 

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

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

134 use a parent directory specification. 

135 """ 

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

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

138 child = None 

139 

140 if not other.isabs(): 

141 # Both are schemeless relative. Use parent implementation 

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

143 # match. 

144 pass 

145 elif other.isabs(): 145 ↛ 149line 145 didn't jump to line 149 because the condition on line 145 was always true

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

147 child = other.join(self.path) 

148 else: 

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

150 

151 if child is None: 

152 return super().relative_to(other, walk_up=walk_up) 

153 return child.relative_to(other, walk_up=walk_up) 

154 

155 @classmethod 

156 def _fixupPathUri( 

157 cls, 

158 parsed: urllib.parse.ParseResult, 

159 root: ResourcePath | None = None, 

160 forceAbsolute: bool = False, 

161 forceDirectory: bool | None = None, 

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

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

164 

165 Parameters 

166 ---------- 

167 parsed : `~urllib.parse.ParseResult` 

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

169 root : `ResourcePath`, optional 

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

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

172 ignored if the supplied path is already absolute or if 

173 ``forceAbsolute`` is `False`. 

174 forceAbsolute : `bool`, optional 

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

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

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

178 absolute path. 

179 forceDirectory : `bool`, optional 

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

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

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

183 status is unknown. 

184 

185 Returns 

186 ------- 

187 modified : `~urllib.parse.ParseResult` 

188 Update result if a URI is being handled. 

189 dirLike : `bool` 

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

191 forceDirectory is True. Otherwise `False`. 

192 

193 Notes 

194 ----- 

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

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

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

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

199 

200 Scheme-less paths are normalized and environment variables are 

201 expanded. 

202 """ 

203 # assume we are not dealing with a directory URI 

204 dirLike = forceDirectory 

205 

206 # Replacement values for the URI 

207 replacements = {} 

208 

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

210 # we quoted it in the constructor so unquote here 

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

212 

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

214 # so expand those here 

215 expandedPath = os.path.expandvars(expandedPath) 

216 

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

218 if os.path.isabs(expandedPath): 

219 replacements["scheme"] = "file" 

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

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

222 elif forceAbsolute: 

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

224 if root is None: 

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

226 else: 

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

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

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

230 # so we trust the user. 

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

232 

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

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

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

236 replacements["scheme"] = "file" 

237 

238 # Keep in OS form for now. 

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

240 else: 

241 # No change needed for relative local path staying relative 

242 # except normalization 

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

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

245 if expandedPath == "": 

246 dirLike = True 

247 

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

249 # track of directory vs file when calling replaceFile 

250 

251 # add the trailing separator only if explicitly required or 

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

253 # separator exists. 

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

255 

256 # Consistency check. 

257 if forceDirectory is False and endsOnSep: 

258 raise ValueError( 

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

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

261 ) 

262 

263 if forceDirectory or endsOnSep or dirLike: 

264 dirLike = True 

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

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

267 

268 if "scheme" in replacements: 

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

270 # and quote 

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

272 

273 # ParseResult is a NamedTuple so _replace is standard API 

274 parsed = parsed._replace(**replacements) 

275 

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

277 # specified for schemeless 

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

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

280 

281 return parsed, dirLike