Coverage for python/lsst/resources/schemeless.py: 94%
74 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-01 11:14 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-01 11:14 +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.
12from __future__ import annotations
14__all__ = ("SchemelessResourcePath",)
16import logging
17import os
18import os.path
19import stat
20import urllib.parse
21from pathlib import PurePath
23from ._resourcePath import ResourcePath
24from .file import FileResourcePath
25from .utils import os2posix
27log = logging.getLogger(__name__)
30class SchemelessResourcePath(FileResourcePath):
31 """Scheme-less URI referring to the local file system or relative URI."""
33 _pathLib = PurePath
34 _pathModule = os.path
35 quotePaths = False
37 @property
38 def ospath(self) -> str:
39 """Path component of the URI localized to current OS."""
40 return self.path
42 def isabs(self) -> bool:
43 """Indicate that the resource is fully specified.
45 For non-schemeless URIs this is always true.
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
55 def abspath(self) -> ResourcePath:
56 """Force a schemeless URI to a file URI.
58 This will include URI quoting of the path.
60 Returns
61 -------
62 file : `FileResourcePath`
63 A new URI using file scheme.
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 )
78 def isdir(self) -> bool:
79 """Return whether this URI is a directory.
81 Returns
82 -------
83 isdir : `bool`
84 `True` if this URI is a directory or looks like a directory,
85 else `False`.
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
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
108 def relative_to(self, other: ResourcePath) -> str | None:
109 """Return the relative path from this URI to the other URI.
111 Parameters
112 ----------
113 other : `ResourcePath`
114 URI to use to calculate the relative path.
116 Returns
117 -------
118 subpath : `str`
119 The sub path of this URI relative to the supplied other URI.
120 Returns `None` if there is no parent child relationship.
121 If this URI is a relative URI but the other is
122 absolute, it is assumed to be in the parent completely unless it
123 starts with ".." (in which case the path is combined and tested).
124 If both URIs are relative, the relative paths are compared
125 for commonality.
127 Notes
128 -----
129 By definition a relative path will be relative to the enclosing
130 absolute parent URI. It will be returned unchanged if it does not
131 use a parent directory specification.
132 """
133 # In some scenarios below a new derived child URI needs to be created
134 # to convert from scheme-less to absolute URI.
135 child = None
137 if not other.isabs():
138 # Both are schemeless relative. Use parent implementation
139 # rather than trying to convert both to file: first since schemes
140 # match.
141 pass
142 elif other.isabs(): 142 ↛ 146line 142 didn't jump to line 146, because the condition on line 142 was never false
143 # Append child to other. This can account for .. in child path.
144 child = other.join(self.path)
145 else:
146 raise RuntimeError(f"Unexpected combination of {child}.relative_to({other}).")
148 if child is None:
149 return super().relative_to(other)
150 return child.relative_to(other)
152 @classmethod
153 def _fixupPathUri(
154 cls,
155 parsed: urllib.parse.ParseResult,
156 root: ResourcePath | None = None,
157 forceAbsolute: bool = False,
158 forceDirectory: bool | None = None,
159 ) -> tuple[urllib.parse.ParseResult, bool | None]:
160 """Fix up relative paths for local file system.
162 Parameters
163 ----------
164 parsed : `~urllib.parse.ParseResult`
165 The result from parsing a URI using `urllib.parse`.
166 root : `ResourcePath`, optional
167 Path to use as root when converting relative to absolute.
168 If `None`, it will be the current working directory. Will be
169 ignored if the supplied path is already absolute or if
170 ``forceAbsolute`` is `False`.
171 forceAbsolute : `bool`, optional
172 If `True`, scheme-less relative URI will be converted to an
173 absolute path using a ``file`` scheme. If `False` scheme-less URI
174 will remain scheme-less and will not be updated to ``file`` or
175 absolute path.
176 forceDirectory : `bool`, optional
177 If `True` forces the URI to end with a separator, otherwise given
178 URI is interpreted as is. `False` can be used to indicate that
179 the URI is known to correspond to a file. `None` means that the
180 status is unknown.
182 Returns
183 -------
184 modified : `~urllib.parse.ParseResult`
185 Update result if a URI is being handled.
186 dirLike : `bool`
187 `True` if given parsed URI has a trailing separator or
188 forceDirectory is True. Otherwise `False`.
190 Notes
191 -----
192 Relative paths are explicitly not supported by RFC8089 but `urllib`
193 does accept URIs of the form ``file:relative/path.ext``. They need
194 to be turned into absolute paths before they can be used. This is
195 always done regardless of the ``forceAbsolute`` parameter.
197 Scheme-less paths are normalized and environment variables are
198 expanded.
199 """
200 # assume we are not dealing with a directory URI
201 dirLike = forceDirectory
203 # Replacement values for the URI
204 replacements = {}
206 # this is a local OS file path which can support tilde expansion.
207 # we quoted it in the constructor so unquote here
208 expandedPath = os.path.expanduser(urllib.parse.unquote(parsed.path))
210 # We might also be receiving a path containing environment variables
211 # so expand those here
212 expandedPath = os.path.expandvars(expandedPath)
214 # Ensure that this becomes a file URI if it is already absolute
215 if os.path.isabs(expandedPath):
216 replacements["scheme"] = "file"
217 # Keep in OS form for now to simplify later logic
218 replacements["path"] = os.path.normpath(expandedPath)
219 elif forceAbsolute:
220 # Need to know the root that should be prepended.
221 if root is None:
222 root_str = os.path.abspath(os.path.curdir)
223 else:
224 if root.scheme and root.scheme != "file": 224 ↛ 225line 224 didn't jump to line 225, because the condition on line 224 was never true
225 raise ValueError(f"The override root must be a file URI not {root.scheme}")
226 # os.path does not care whether something is dirLike or not
227 # so we trust the user.
228 root_str = os.path.abspath(root.ospath)
230 # Convert to "file" scheme to make it consistent with the above
231 # decision. It makes no sense for sometimes an absolute path
232 # to be a file URI and sometimes for it not to be.
233 replacements["scheme"] = "file"
235 # Keep in OS form for now.
236 replacements["path"] = os.path.normpath(os.path.join(root_str, expandedPath))
237 else:
238 # No change needed for relative local path staying relative
239 # except normalization
240 replacements["path"] = os.path.normpath(expandedPath)
241 # normalization of empty path returns "." so we are dirLike
242 if expandedPath == "":
243 dirLike = True
245 # normpath strips trailing "/" which makes it hard to keep
246 # track of directory vs file when calling replaceFile
248 # add the trailing separator only if explicitly required or
249 # if it was stripped by normpath. Acknowledge that trailing
250 # separator exists.
251 endsOnSep = expandedPath.endswith(os.sep) and not replacements["path"].endswith(os.sep)
253 # Consistency check.
254 if forceDirectory is False and endsOnSep:
255 raise ValueError(
256 f"URI {parsed.geturl()} ends with {os.sep} but "
257 "forceDirectory parameter declares it to be a file."
258 )
260 if forceDirectory or endsOnSep or dirLike:
261 dirLike = True
262 if not replacements["path"].endswith(os.sep):
263 replacements["path"] += os.sep
265 if "scheme" in replacements:
266 # This is now meant to be a URI path so force to posix
267 # and quote
268 replacements["path"] = urllib.parse.quote(os2posix(replacements["path"]))
270 # ParseResult is a NamedTuple so _replace is standard API
271 parsed = parsed._replace(**replacements)
273 # We do allow fragment but do not expect params or query to be
274 # specified for schemeless
275 if parsed.params or parsed.query: 275 ↛ 276line 275 didn't jump to line 276, because the condition on line 275 was never true
276 log.warning("Additional items unexpectedly encountered in schemeless URI: %s", parsed.geturl())
278 return parsed, dirLike