Coverage for python/lsst/resources/schemeless.py: 82%
71 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-30 11:34 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-30 11:34 +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 urllib.parse
20from pathlib import PurePath
22from ._resourcePath import ResourcePath
23from .file import FileResourcePath
24from .utils import os2posix
26log = logging.getLogger(__name__)
29class SchemelessResourcePath(FileResourcePath):
30 """Scheme-less URI referring to the local file system or relative URI."""
32 _pathLib = PurePath
33 _pathModule = os.path
34 quotePaths = False
36 @property
37 def ospath(self) -> str:
38 """Path component of the URI localized to current OS."""
39 return self.path
41 def isabs(self) -> bool:
42 """Indicate that the resource is fully specified.
44 For non-schemeless URIs this is always true.
46 Returns
47 -------
48 isabs : `bool`
49 `True` if the file is absolute, `False` otherwise.
50 """
51 return os.path.isabs(self.ospath)
53 def abspath(self) -> ResourcePath:
54 """Force a schemeless URI to a file URI.
56 This will include URI quoting of the path.
58 Returns
59 -------
60 file : `FileResourcePath`
61 A new URI using file scheme.
63 Notes
64 -----
65 The current working directory will be used to convert this scheme-less
66 URI to an absolute path.
67 """
68 # Convert this URI to a string so that any fragments will be
69 # processed correctly by the ResourcePath constructor. We provide
70 # the options that will force the code below in _fixupPathUri to
71 # return a file URI from a scheme-less one.
72 return ResourcePath(
73 str(self), forceAbsolute=True, forceDirectory=self.isdir(), isTemporary=self.isTemporary
74 )
76 def relative_to(self, other: ResourcePath) -> str | None:
77 """Return the relative path from this URI to the other URI.
79 Parameters
80 ----------
81 other : `ResourcePath`
82 URI to use to calculate the relative path.
84 Returns
85 -------
86 subpath : `str`
87 The sub path of this URI relative to the supplied other URI.
88 Returns `None` if there is no parent child relationship.
89 If this URI is a relative URI but the other is
90 absolute, it is assumed to be in the parent completely unless it
91 starts with ".." (in which case the path is combined and tested).
92 If both URIs are relative, the relative paths are compared
93 for commonality.
95 Notes
96 -----
97 By definition a relative path will be relative to the enclosing
98 absolute parent URI. It will be returned unchanged if it does not
99 use a parent directory specification.
100 """
101 # In some scenarios below a new derived child URI needs to be created
102 # to convert from scheme-less to absolute URI.
103 child = None
105 if not self.isabs() and not other.isabs():
106 # Both are schemeless relative. Use parent implementation
107 # rather than trying to convert both to file: first since schemes
108 # match.
109 pass
110 elif not self.isabs() and other.isabs(): 110 ↛ 113line 110 didn't jump to line 113, because the condition on line 110 was never false
111 # Append child to other. This can account for .. in child path.
112 child = other.join(self.path)
113 elif self.isabs() and not other.isabs():
114 # Finding common paths is not possible if the parent is
115 # relative and the child is absolute.
116 return None
117 elif self.isabs() and other.isabs():
118 # Both are absolute so convert schemeless to file
119 # if necessary.
120 child = self.abspath()
121 if not other.scheme:
122 other = other.abspath()
123 else:
124 raise RuntimeError(f"Unexpected combination of {child}.relative_to({other}).")
126 if child is None:
127 return super().relative_to(other)
128 return child.relative_to(other)
130 @classmethod
131 def _fixupPathUri(
132 cls,
133 parsed: urllib.parse.ParseResult,
134 root: ResourcePath | None = None,
135 forceAbsolute: bool = False,
136 forceDirectory: bool = False,
137 ) -> tuple[urllib.parse.ParseResult, bool]:
138 """Fix up relative paths for local file system.
140 Parameters
141 ----------
142 parsed : `~urllib.parse.ParseResult`
143 The result from parsing a URI using `urllib.parse`.
144 root : `ResourcePath`, optional
145 Path to use as root when converting relative to absolute.
146 If `None`, it will be the current working directory. Will be
147 ignored if the supplied path is already absolute or if
148 ``forceAbsolute`` is `False`.
149 forceAbsolute : `bool`, optional
150 If `True`, scheme-less relative URI will be converted to an
151 absolute path using a ``file`` scheme. If `False` scheme-less URI
152 will remain scheme-less and will not be updated to ``file`` or
153 absolute path.
154 forceDirectory : `bool`, optional
155 If `True` forces the URI to end with a separator, otherwise given
156 URI is interpreted as is.
158 Returns
159 -------
160 modified : `~urllib.parse.ParseResult`
161 Update result if a URI is being handled.
162 dirLike : `bool`
163 `True` if given parsed URI has a trailing separator or
164 forceDirectory is True. Otherwise `False`.
166 Notes
167 -----
168 Relative paths are explicitly not supported by RFC8089 but `urllib`
169 does accept URIs of the form ``file:relative/path.ext``. They need
170 to be turned into absolute paths before they can be used. This is
171 always done regardless of the ``forceAbsolute`` parameter.
173 Scheme-less paths are normalized and environment variables are
174 expanded.
175 """
176 # assume we are not dealing with a directory URI
177 dirLike = False
179 # Replacement values for the URI
180 replacements = {}
182 # this is a local OS file path which can support tilde expansion.
183 # we quoted it in the constructor so unquote here
184 expandedPath = os.path.expanduser(urllib.parse.unquote(parsed.path))
186 # We might also be receiving a path containing environment variables
187 # so expand those here
188 expandedPath = os.path.expandvars(expandedPath)
190 # Ensure that this becomes a file URI if it is already absolute
191 if os.path.isabs(expandedPath):
192 replacements["scheme"] = "file"
193 # Keep in OS form for now to simplify later logic
194 replacements["path"] = os.path.normpath(expandedPath)
195 elif forceAbsolute:
196 # Need to know the root that should be prepended.
197 if root is None:
198 root_str = os.path.abspath(os.path.curdir)
199 else:
200 if root.scheme and root.scheme != "file": 200 ↛ 201line 200 didn't jump to line 201, because the condition on line 200 was never true
201 raise ValueError(f"The override root must be a file URI not {root.scheme}")
202 # os.path does not care whether something is dirLike or not
203 # so we trust the user.
204 root_str = os.path.abspath(root.ospath)
206 # Convert to "file" scheme to make it consistent with the above
207 # decision. It makes no sense for sometimes an absolute path
208 # to be a file URI and sometimes for it not to be.
209 replacements["scheme"] = "file"
211 # Keep in OS form for now.
212 replacements["path"] = os.path.normpath(os.path.join(root_str, expandedPath))
213 else:
214 # No change needed for relative local path staying relative
215 # except normalization
216 replacements["path"] = os.path.normpath(expandedPath)
217 # normalization of empty path returns "." so we are dirLike
218 if expandedPath == "":
219 dirLike = True
221 # normpath strips trailing "/" which makes it hard to keep
222 # track of directory vs file when calling replaceFile
224 # For local file system we can explicitly check to see if this
225 # really is a directory. The URI might point to a location that
226 # does not exists yet but all that matters is if it is a directory
227 # then we make sure use that fact. No need to do the check if
228 # we are already being told.
229 if not forceDirectory and os.path.isdir(replacements["path"]):
230 forceDirectory = True
232 # add the trailing separator only if explicitly required or
233 # if it was stripped by normpath. Acknowledge that trailing
234 # separator exists.
235 endsOnSep = expandedPath.endswith(os.sep) and not replacements["path"].endswith(os.sep)
236 if forceDirectory or endsOnSep or dirLike:
237 dirLike = True
238 if not replacements["path"].endswith(os.sep):
239 replacements["path"] += os.sep
241 if "scheme" in replacements:
242 # This is now meant to be a URI path so force to posix
243 # and quote
244 replacements["path"] = urllib.parse.quote(os2posix(replacements["path"]))
246 # ParseResult is a NamedTuple so _replace is standard API
247 parsed = parsed._replace(**replacements)
249 # We do allow fragment but do not expect params or query to be
250 # specified for schemeless
251 if parsed.params or parsed.query: 251 ↛ 252line 251 didn't jump to line 252, because the condition on line 251 was never true
252 log.warning("Additional items unexpectedly encountered in schemeless URI: %s", parsed.geturl())
254 return parsed, dirLike