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

72 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-03-30 09:30 +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 

14import logging 

15import os 

16import os.path 

17import urllib.parse 

18 

19__all__ = ("SchemelessResourcePath",) 

20 

21from pathlib import PurePath 

22from typing import Optional, Tuple, Union 

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(): 

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(): 119 ↛ 126line 119 didn't jump to line 126, because the condition on line 119 was never false

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[Union[str, 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 : `str` or `ResourcePath`, optional 

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

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

149 is a local file system path, or a file URI. 

150 forceAbsolute : `bool`, optional 

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

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

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

154 absolute path. 

155 forceDirectory : `bool`, optional 

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

157 URI is interpreted as is. 

158 

159 Returns 

160 ------- 

161 modified : `~urllib.parse.ParseResult` 

162 Update result if a URI is being handled. 

163 dirLike : `bool` 

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

165 forceDirectory is True. Otherwise `False`. 

166 

167 Notes 

168 ----- 

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

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

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

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

173 

174 Scheme-less paths are normalized and environment variables are 

175 expanded. 

176 """ 

177 # assume we are not dealing with a directory URI 

178 dirLike = False 

179 

180 # Replacement values for the URI 

181 replacements = {} 

182 

183 if root is None: 

184 root = os.path.abspath(os.path.curdir) 

185 elif isinstance(root, ResourcePath): 

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

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

188 root = os.path.abspath(root.ospath) 

189 

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

191 # we quoted it in the constructor so unquote here 

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

193 

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

195 # so expand those here 

196 expandedPath = os.path.expandvars(expandedPath) 

197 

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

199 if os.path.isabs(expandedPath): 

200 replacements["scheme"] = "file" 

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

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

203 elif forceAbsolute: 

204 # This can stay in OS path form, do not change to file 

205 # scheme. 

206 replacements["path"] = os.path.normpath(os.path.join(root, expandedPath)) 

207 else: 

208 # No change needed for relative local path staying relative 

209 # except normalization 

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

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

212 if expandedPath == "": 

213 dirLike = True 

214 

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

216 # track of directory vs file when calling replaceFile 

217 

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

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

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

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

222 # we are already being told. 

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

224 forceDirectory = True 

225 

226 # add the trailing separator only if explicitly required or 

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

228 # separator exists. 

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

230 if forceDirectory or endsOnSep or dirLike: 

231 dirLike = True 

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

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

234 

235 if "scheme" in replacements: 

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

237 # and quote 

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

239 

240 # ParseResult is a NamedTuple so _replace is standard API 

241 parsed = parsed._replace(**replacements) 

242 

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

244 # specified for schemeless 

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

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

247 

248 return parsed, dirLike