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

72 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-15 02:25 -0700

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 

14import logging 

15import os 

16import os.path 

17import urllib.parse 

18 

19__all__ = ("SchemelessResourcePath",) 

20 

21from pathlib import PurePath 

22from typing import Optional, Tuple 

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. 

52 """ 

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

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.isdir(), isTemporary=self.isTemporary 

76 ) 

77 

78 def relative_to(self, other: ResourcePath) -> Optional[str]: 

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

80 

81 Parameters 

82 ---------- 

83 other : `ResourcePath` 

84 URI to use to calculate the relative path. 

85 

86 Returns 

87 ------- 

88 subpath : `str` 

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

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

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

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

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

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

95 for commonality. 

96 

97 Notes 

98 ----- 

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

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

101 use a parent directory specification. 

102 """ 

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

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

105 child = None 

106 

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

108 # Both are schemeless relative. Use parent implementation 

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

110 # match. 

111 pass 

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

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

114 child = other.join(self.path) 

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

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

117 # relative and the child is absolute. 

118 return None 

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

120 # Both are absolute so convert schemeless to file 

121 # if necessary. 

122 child = self.abspath() 

123 if not other.scheme: 

124 other = other.abspath() 

125 else: 

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

127 

128 if child is None: 

129 return super().relative_to(other) 

130 return child.relative_to(other) 

131 

132 @classmethod 

133 def _fixupPathUri( 

134 cls, 

135 parsed: urllib.parse.ParseResult, 

136 root: Optional[ResourcePath] = None, 

137 forceAbsolute: bool = False, 

138 forceDirectory: bool = False, 

139 ) -> Tuple[urllib.parse.ParseResult, bool]: 

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

141 

142 Parameters 

143 ---------- 

144 parsed : `~urllib.parse.ParseResult` 

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

146 root : `ResourcePath`, optional 

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

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

149 ignored if the supplied path is already absolute or if 

150 ``forceAbsolute`` is `False`. 

151 forceAbsolute : `bool`, optional 

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

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

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

155 absolute path. 

156 forceDirectory : `bool`, optional 

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

158 URI is interpreted as is. 

159 

160 Returns 

161 ------- 

162 modified : `~urllib.parse.ParseResult` 

163 Update result if a URI is being handled. 

164 dirLike : `bool` 

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

166 forceDirectory is True. Otherwise `False`. 

167 

168 Notes 

169 ----- 

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

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

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

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

174 

175 Scheme-less paths are normalized and environment variables are 

176 expanded. 

177 """ 

178 # assume we are not dealing with a directory URI 

179 dirLike = False 

180 

181 # Replacement values for the URI 

182 replacements = {} 

183 

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

185 # we quoted it in the constructor so unquote here 

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

187 

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

189 # so expand those here 

190 expandedPath = os.path.expandvars(expandedPath) 

191 

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

193 if os.path.isabs(expandedPath): 

194 replacements["scheme"] = "file" 

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

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

197 elif forceAbsolute: 

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

199 if root is None: 

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

201 else: 

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

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

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

205 # so we trust the user. 

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

207 

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

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

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

211 replacements["scheme"] = "file" 

212 

213 # Keep in OS form for now. 

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

215 else: 

216 # No change needed for relative local path staying relative 

217 # except normalization 

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

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

220 if expandedPath == "": 

221 dirLike = True 

222 

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

224 # track of directory vs file when calling replaceFile 

225 

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

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

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

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

230 # we are already being told. 

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

232 forceDirectory = True 

233 

234 # add the trailing separator only if explicitly required or 

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

236 # separator exists. 

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

238 if forceDirectory or endsOnSep or dirLike: 

239 dirLike = True 

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

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

242 

243 if "scheme" in replacements: 

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

245 # and quote 

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

247 

248 # ParseResult is a NamedTuple so _replace is standard API 

249 parsed = parsed._replace(**replacements) 

250 

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

252 # specified for schemeless 

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

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

255 

256 return parsed, dirLike