Coverage for python/lsst/resources/schemeless.py: 94%
74 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-07 01:44 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-07 01:44 -0800
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, walk_up: bool = False) -> 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.
115 walk_up : `bool`, optional
116 Control whether "``..``" can be used to resolve a relative path.
117 Default is `False`. Can not be `True` on Python version 3.11.
119 Returns
120 -------
121 subpath : `str`
122 The sub path of this URI relative to the supplied other URI.
123 Returns `None` if there is no parent child relationship.
124 If this URI is a relative URI but the other is
125 absolute, it is assumed to be in the parent completely unless it
126 starts with ".." (in which case the path is combined and tested).
127 If both URIs are relative, the relative paths are compared
128 for commonality.
130 Notes
131 -----
132 By definition a relative path will be relative to the enclosing
133 absolute parent URI. It will be returned unchanged if it does not
134 use a parent directory specification.
135 """
136 # In some scenarios below a new derived child URI needs to be created
137 # to convert from scheme-less to absolute URI.
138 child = None
140 if not other.isabs():
141 # Both are schemeless relative. Use parent implementation
142 # rather than trying to convert both to file: first since schemes
143 # match.
144 pass
145 elif other.isabs(): 145 ↛ 149line 145 didn't jump to line 149 because the condition on line 145 was always true
146 # Append child to other. This can account for .. in child path.
147 child = other.join(self.path)
148 else:
149 raise RuntimeError(f"Unexpected combination of {child}.relative_to({other}).")
151 if child is None:
152 return super().relative_to(other, walk_up=walk_up)
153 return child.relative_to(other, walk_up=walk_up)
155 @classmethod
156 def _fixupPathUri(
157 cls,
158 parsed: urllib.parse.ParseResult,
159 root: ResourcePath | None = None,
160 forceAbsolute: bool = False,
161 forceDirectory: bool | None = None,
162 ) -> tuple[urllib.parse.ParseResult, bool | None]:
163 """Fix up relative paths for local file system.
165 Parameters
166 ----------
167 parsed : `~urllib.parse.ParseResult`
168 The result from parsing a URI using `urllib.parse`.
169 root : `ResourcePath`, optional
170 Path to use as root when converting relative to absolute.
171 If `None`, it will be the current working directory. Will be
172 ignored if the supplied path is already absolute or if
173 ``forceAbsolute`` is `False`.
174 forceAbsolute : `bool`, optional
175 If `True`, scheme-less relative URI will be converted to an
176 absolute path using a ``file`` scheme. If `False` scheme-less URI
177 will remain scheme-less and will not be updated to ``file`` or
178 absolute path.
179 forceDirectory : `bool`, optional
180 If `True` forces the URI to end with a separator, otherwise given
181 URI is interpreted as is. `False` can be used to indicate that
182 the URI is known to correspond to a file. `None` means that the
183 status is unknown.
185 Returns
186 -------
187 modified : `~urllib.parse.ParseResult`
188 Update result if a URI is being handled.
189 dirLike : `bool`
190 `True` if given parsed URI has a trailing separator or
191 forceDirectory is True. Otherwise `False`.
193 Notes
194 -----
195 Relative paths are explicitly not supported by RFC8089 but `urllib`
196 does accept URIs of the form ``file:relative/path.ext``. They need
197 to be turned into absolute paths before they can be used. This is
198 always done regardless of the ``forceAbsolute`` parameter.
200 Scheme-less paths are normalized and environment variables are
201 expanded.
202 """
203 # assume we are not dealing with a directory URI
204 dirLike = forceDirectory
206 # Replacement values for the URI
207 replacements = {}
209 # this is a local OS file path which can support tilde expansion.
210 # we quoted it in the constructor so unquote here
211 expandedPath = os.path.expanduser(urllib.parse.unquote(parsed.path))
213 # We might also be receiving a path containing environment variables
214 # so expand those here
215 expandedPath = os.path.expandvars(expandedPath)
217 # Ensure that this becomes a file URI if it is already absolute
218 if os.path.isabs(expandedPath):
219 replacements["scheme"] = "file"
220 # Keep in OS form for now to simplify later logic
221 replacements["path"] = os.path.normpath(expandedPath)
222 elif forceAbsolute:
223 # Need to know the root that should be prepended.
224 if root is None:
225 root_str = os.path.abspath(os.path.curdir)
226 else:
227 if root.scheme and root.scheme != "file": 227 ↛ 228line 227 didn't jump to line 228 because the condition on line 227 was never true
228 raise ValueError(f"The override root must be a file URI not {root.scheme}")
229 # os.path does not care whether something is dirLike or not
230 # so we trust the user.
231 root_str = os.path.abspath(root.ospath)
233 # Convert to "file" scheme to make it consistent with the above
234 # decision. It makes no sense for sometimes an absolute path
235 # to be a file URI and sometimes for it not to be.
236 replacements["scheme"] = "file"
238 # Keep in OS form for now.
239 replacements["path"] = os.path.normpath(os.path.join(root_str, expandedPath))
240 else:
241 # No change needed for relative local path staying relative
242 # except normalization
243 replacements["path"] = os.path.normpath(expandedPath)
244 # normalization of empty path returns "." so we are dirLike
245 if expandedPath == "":
246 dirLike = True
248 # normpath strips trailing "/" which makes it hard to keep
249 # track of directory vs file when calling replaceFile
251 # add the trailing separator only if explicitly required or
252 # if it was stripped by normpath. Acknowledge that trailing
253 # separator exists.
254 endsOnSep = expandedPath.endswith(os.sep) and not replacements["path"].endswith(os.sep)
256 # Consistency check.
257 if forceDirectory is False and endsOnSep:
258 raise ValueError(
259 f"URI {parsed.geturl()} ends with {os.sep} but "
260 "forceDirectory parameter declares it to be a file."
261 )
263 if forceDirectory or endsOnSep or dirLike:
264 dirLike = True
265 if not replacements["path"].endswith(os.sep):
266 replacements["path"] += os.sep
268 if "scheme" in replacements:
269 # This is now meant to be a URI path so force to posix
270 # and quote
271 replacements["path"] = urllib.parse.quote(os2posix(replacements["path"]))
273 # ParseResult is a NamedTuple so _replace is standard API
274 parsed = parsed._replace(**replacements)
276 # We do allow fragment but do not expect params or query to be
277 # specified for schemeless
278 if parsed.params or parsed.query: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 log.warning("Additional items unexpectedly encountered in schemeless URI: %s", parsed.geturl())
281 return parsed, dirLike