Coverage for python/lsst/resources/schemeless.py: 96%
72 statements
« prev ^ index » next coverage.py v6.4.1, created at 2022-07-03 01:04 -0700
« prev ^ index » next coverage.py v6.4.1, created at 2022-07-03 01:04 -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, Union
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():
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}).")
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[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.
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.
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`.
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.
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
180 # Replacement values for the URI
181 replacements = {}
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)
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))
194 # We might also be receiving a path containing environment variables
195 # so expand those here
196 expandedPath = os.path.expandvars(expandedPath)
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
215 # normpath strips trailing "/" which makes it hard to keep
216 # track of directory vs file when calling replaceFile
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
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
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"]))
240 # ParseResult is a NamedTuple so _replace is standard API
241 parsed = parsed._replace(**replacements)
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())
248 return parsed, dirLike