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

71 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__ = ("SchemelessResourcePath",) 

15 

16import logging 

17import os 

18import os.path 

19import urllib.parse 

20from pathlib import PurePath 

21 

22from ._resourcePath import ResourcePath 

23from .file import FileResourcePath 

24from .utils import os2posix 

25 

26log = logging.getLogger(__name__) 

27 

28 

29class SchemelessResourcePath(FileResourcePath): 

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

31 

32 _pathLib = PurePath 

33 _pathModule = os.path 

34 quotePaths = False 

35 

36 @property 

37 def ospath(self) -> str: 

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

39 return self.path 

40 

41 def isabs(self) -> bool: 

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

43 

44 For non-schemeless URIs this is always true. 

45 

46 Returns 

47 ------- 

48 isabs : `bool` 

49 `True` if the file is absolute, `False` otherwise. 

50 """ 

51 return os.path.isabs(self.ospath) 

52 

53 def abspath(self) -> ResourcePath: 

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

55 

56 This will include URI quoting of the path. 

57 

58 Returns 

59 ------- 

60 file : `FileResourcePath` 

61 A new URI using file scheme. 

62 

63 Notes 

64 ----- 

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

66 URI to an absolute path. 

67 """ 

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

69 # processed correctly by the ResourcePath constructor. We provide 

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

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

72 return ResourcePath( 

73 str(self), forceAbsolute=True, forceDirectory=self.isdir(), isTemporary=self.isTemporary 

74 ) 

75 

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

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

78 

79 Parameters 

80 ---------- 

81 other : `ResourcePath` 

82 URI to use to calculate the relative path. 

83 

84 Returns 

85 ------- 

86 subpath : `str` 

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

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

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

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

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

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

93 for commonality. 

94 

95 Notes 

96 ----- 

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

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

99 use a parent directory specification. 

100 """ 

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

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

103 child = None 

104 

105 if not self.isabs() and not other.isabs(): 

106 # Both are schemeless relative. Use parent implementation 

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

108 # match. 

109 pass 

110 elif not self.isabs() and other.isabs(): 110 ↛ 113line 110 didn't jump to line 113, because the condition on line 110 was never false

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

112 child = other.join(self.path) 

113 elif self.isabs() and not other.isabs(): 

114 # Finding common paths is not possible if the parent is 

115 # relative and the child is absolute. 

116 return None 

117 elif self.isabs() and other.isabs(): 

118 # Both are absolute so convert schemeless to file 

119 # if necessary. 

120 child = self.abspath() 

121 if not other.scheme: 

122 other = other.abspath() 

123 else: 

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

125 

126 if child is None: 

127 return super().relative_to(other) 

128 return child.relative_to(other) 

129 

130 @classmethod 

131 def _fixupPathUri( 

132 cls, 

133 parsed: urllib.parse.ParseResult, 

134 root: ResourcePath | None = None, 

135 forceAbsolute: bool = False, 

136 forceDirectory: bool = False, 

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

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

139 

140 Parameters 

141 ---------- 

142 parsed : `~urllib.parse.ParseResult` 

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

144 root : `ResourcePath`, optional 

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

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

147 ignored if the supplied path is already absolute or if 

148 ``forceAbsolute`` is `False`. 

149 forceAbsolute : `bool`, optional 

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

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

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

153 absolute path. 

154 forceDirectory : `bool`, optional 

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

156 URI is interpreted as is. 

157 

158 Returns 

159 ------- 

160 modified : `~urllib.parse.ParseResult` 

161 Update result if a URI is being handled. 

162 dirLike : `bool` 

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

164 forceDirectory is True. Otherwise `False`. 

165 

166 Notes 

167 ----- 

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

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

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

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

172 

173 Scheme-less paths are normalized and environment variables are 

174 expanded. 

175 """ 

176 # assume we are not dealing with a directory URI 

177 dirLike = False 

178 

179 # Replacement values for the URI 

180 replacements = {} 

181 

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

183 # we quoted it in the constructor so unquote here 

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

185 

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

187 # so expand those here 

188 expandedPath = os.path.expandvars(expandedPath) 

189 

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

191 if os.path.isabs(expandedPath): 

192 replacements["scheme"] = "file" 

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

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

195 elif forceAbsolute: 

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

197 if root is None: 

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

199 else: 

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

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

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

203 # so we trust the user. 

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

205 

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

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

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

209 replacements["scheme"] = "file" 

210 

211 # Keep in OS form for now. 

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

213 else: 

214 # No change needed for relative local path staying relative 

215 # except normalization 

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

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

218 if expandedPath == "": 

219 dirLike = True 

220 

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

222 # track of directory vs file when calling replaceFile 

223 

224 # For local file system we can explicitly check to see if this 

225 # really is a directory. The URI might point to a location that 

226 # does not exists yet but all that matters is if it is a directory 

227 # then we make sure use that fact. No need to do the check if 

228 # we are already being told. 

229 if not forceDirectory and os.path.isdir(replacements["path"]): 

230 forceDirectory = True 

231 

232 # add the trailing separator only if explicitly required or 

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

234 # separator exists. 

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

236 if forceDirectory or endsOnSep or dirLike: 

237 dirLike = True 

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

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

240 

241 if "scheme" in replacements: 

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

243 # and quote 

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

245 

246 # ParseResult is a NamedTuple so _replace is standard API 

247 parsed = parsed._replace(**replacements) 

248 

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

250 # specified for schemeless 

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

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

253 

254 return parsed, dirLike