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

80 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:32 +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 re 

20import stat 

21import urllib.parse 

22from pathlib import PurePath 

23 

24from ._resourcePath import ResourcePath 

25from .file import FileResourcePath 

26from .utils import os2posix 

27 

28log = logging.getLogger(__name__) 

29 

30 

31class SchemelessResourcePath(FileResourcePath): 

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

33 

34 _pathLib = PurePath 

35 _pathModule = os.path 

36 quotePaths = False 

37 

38 @property 

39 def ospath(self) -> str: 

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

41 return self.path 

42 

43 def isabs(self) -> bool: 

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

45 

46 For non-schemeless URIs this is always true. 

47 

48 Returns 

49 ------- 

50 isabs : `bool` 

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

52 be `False` for schemeless URIs. 

53 """ 

54 return False 

55 

56 def abspath(self) -> ResourcePath: 

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

58 

59 This will include URI quoting of the path. 

60 

61 Returns 

62 ------- 

63 file : `FileResourcePath` 

64 A new URI using file scheme. 

65 

66 Notes 

67 ----- 

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

69 URI to an absolute path. 

70 """ 

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

72 # processed correctly by the ResourcePath constructor. We provide 

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

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

75 return ResourcePath( 

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

77 ) 

78 

79 def isdir(self) -> bool: 

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

81 

82 Returns 

83 ------- 

84 isdir : `bool` 

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

86 else `False`. 

87 

88 Notes 

89 ----- 

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

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

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

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

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

95 in case the working directory has been updated. 

96 """ 

97 if self.dirLike is None: 

98 try: 

99 status = os.stat(self.ospath) 

100 except FileNotFoundError: 

101 # Do not update dirLike flag. 

102 return False 

103 

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

105 # not and changing directory might change the answer. 

106 return stat.S_ISDIR(status.st_mode) 

107 return self.dirLike 

108 

109 def relative_to(self, other: ResourcePath, walk_up: bool = False) -> str | None: 

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

111 

112 Parameters 

113 ---------- 

114 other : `ResourcePath` 

115 URI to use to calculate the relative path. 

116 walk_up : `bool`, optional 

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

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

119 

120 Returns 

121 ------- 

122 subpath : `str` 

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

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

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

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

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

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

129 for commonality. 

130 

131 Notes 

132 ----- 

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

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

135 use a parent directory specification. 

136 """ 

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

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

139 child = None 

140 

141 if not other.isabs(): 

142 # Both are schemeless relative. Use parent implementation 

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

144 # match. 

145 pass 

146 elif other.isabs(): 

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

148 child = other.join(self.path) 

149 else: 

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

151 

152 if child is None: 

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

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

155 

156 @classmethod 

157 def _fixupPathUri( 

158 cls, 

159 parsed: urllib.parse.ParseResult, 

160 root: ResourcePath | None = None, 

161 forceAbsolute: bool = False, 

162 forceDirectory: bool | None = None, 

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

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

165 

166 Parameters 

167 ---------- 

168 parsed : `~urllib.parse.ParseResult` 

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

170 root : `ResourcePath`, optional 

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

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

173 ignored if the supplied path is already absolute or if 

174 ``forceAbsolute`` is `False`. 

175 forceAbsolute : `bool`, optional 

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

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

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

179 absolute path. 

180 forceDirectory : `bool`, optional 

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

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

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

184 status is unknown. 

185 

186 Returns 

187 ------- 

188 modified : `~urllib.parse.ParseResult` 

189 Update result if a URI is being handled. 

190 dirLike : `bool` 

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

192 forceDirectory is True. Otherwise `False`. 

193 

194 Notes 

195 ----- 

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

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

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

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

200 

201 Scheme-less paths are normalized and environment variables are 

202 expanded. 

203 """ 

204 # assume we are not dealing with a directory URI 

205 dirLike = forceDirectory 

206 

207 # Replacement values for the URI 

208 replacements = {} 

209 

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

211 # we quoted it in the constructor so unquote here 

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

213 

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

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 

225 expandedPath = os.path.expandvars(expandedPath) 

226 

227 # Ensure that this becomes a file URI if it is already absolute, unless 

228 # we already overrode it above. 

229 if os.path.isabs(expandedPath): 

230 if "scheme" not in replacements: 

231 replacements["scheme"] = "file" 

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

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

234 elif forceAbsolute: 

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

236 if root is None: 

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

238 else: 

239 if root.scheme and root.scheme != "file": 

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

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

242 # so we trust the user. 

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

244 

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

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

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

248 replacements["scheme"] = "file" 

249 

250 # Keep in OS form for now. 

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

252 else: 

253 # No change needed for relative local path staying relative 

254 # except normalization 

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

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

257 if expandedPath == "": 

258 dirLike = True 

259 

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

261 # track of directory vs file when calling replaceFile 

262 

263 # add the trailing separator only if explicitly required or 

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

265 # separator exists. 

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

267 

268 # Consistency check. 

269 if forceDirectory is False and endsOnSep: 

270 raise ValueError( 

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

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

273 ) 

274 

275 if forceDirectory or endsOnSep or dirLike: 

276 dirLike = True 

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

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

279 

280 if "scheme" in replacements and replacements["scheme"] == "file": 

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

282 # and quote. EUPS URIs are not quoted. 

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

284 

285 # ParseResult is a NamedTuple so _replace is standard API 

286 parsed = parsed._replace(**replacements) 

287 

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

289 # specified for schemeless 

290 if parsed.params or parsed.query: 

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

292 

293 return parsed, dirLike