Coverage for python/lsst/resources/schemeless.py: 82%
72 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-20 03:07 -0700
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-20 03:07 -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.
12from __future__ import annotations
14import logging
15import os
16import os.path
17import urllib.parse
19__all__ = ("SchemelessResourcePath",)
21from pathlib import PurePath
22from typing import Optional, Tuple
24from ._resourcePath import ResourcePath
25from .file import FileResourcePath
26from .utils import os2posix
28log = logging.getLogger(__name__)
31class SchemelessResourcePath(FileResourcePath):
32 """Scheme-less URI referring to the local file system or relative URI."""
34 _pathLib = PurePath
35 _pathModule = os.path
36 quotePaths = False
38 @property
39 def ospath(self) -> str:
40 """Path component of the URI localized to current OS."""
41 return self.path
43 def isabs(self) -> bool:
44 """Indicate that the resource is fully specified.
46 For non-schemeless URIs this is always true.
48 Returns
49 -------
50 isabs : `bool`
51 `True` if the file is absolute, `False` otherwise.
52 """
53 return os.path.isabs(self.ospath)
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.isdir(), isTemporary=self.isTemporary
76 )
78 def relative_to(self, other: ResourcePath) -> Optional[str]:
79 """Return the relative path from this URI to the other URI.
81 Parameters
82 ----------
83 other : `ResourcePath`
84 URI to use to calculate the relative path.
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.
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
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}).")
128 if child is None:
129 return super().relative_to(other)
130 return child.relative_to(other)
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.
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.
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`.
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.
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
181 # Replacement values for the URI
182 replacements = {}
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))
188 # We might also be receiving a path containing environment variables
189 # so expand those here
190 expandedPath = os.path.expandvars(expandedPath)
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)
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"
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
223 # normpath strips trailing "/" which makes it hard to keep
224 # track of directory vs file when calling replaceFile
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
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
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"]))
248 # ParseResult is a NamedTuple so _replace is standard API
249 parsed = parsed._replace(**replacements)
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())
256 return parsed, dirLike