Coverage for python / lsst / resources / schemeless.py: 0%
80 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 08:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 08:44 +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 re
20import stat
21import urllib.parse
22from pathlib import PurePath
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. Will always
52 be `False` for schemeless URIs.
53 """
54 return False
56 def abspath(self) -> ResourcePath:
57 """Force a schemeless URI to a file URI.
59 This will include URI quoting of the path.
61 Returns
62 -------
63 file : `FileResourcePath`
64 A new URI using file scheme.
66 Notes
67 -----
68 The current working directory will be used to convert this scheme-less
69 URI to an absolute path.
70 """
71 # Convert this URI to a string so that any fragments will be
72 # processed correctly by the ResourcePath constructor. We provide
73 # the options that will force the code below in _fixupPathUri to
74 # return a file URI from a scheme-less one.
75 return ResourcePath(
76 str(self), forceAbsolute=True, forceDirectory=self.dirLike, isTemporary=self.isTemporary
77 )
79 def isdir(self) -> bool:
80 """Return whether this URI is a directory.
82 Returns
83 -------
84 isdir : `bool`
85 `True` if this URI is a directory or looks like a directory,
86 else `False`.
88 Notes
89 -----
90 If the URI is not known to refer to a file or a directory the file
91 system will be checked. The relative path will be resolved using
92 the current working directory. If the path can not be found, `False`
93 will be returned (matching `os.path.isdir` semantics) but the result
94 will not be stored in ``dirLike`` and will be checked again on request
95 in case the working directory has been updated.
96 """
97 if self.dirLike is None:
98 try:
99 status = os.stat(self.ospath)
100 except FileNotFoundError:
101 # Do not update dirLike flag.
102 return False
104 # Do not cache. We do not know if this really refers to a file or
105 # not and changing directory might change the answer.
106 return stat.S_ISDIR(status.st_mode)
107 return self.dirLike
109 def relative_to(self, other: ResourcePath, walk_up: bool = False) -> str | None:
110 """Return the relative path from this URI to the other URI.
112 Parameters
113 ----------
114 other : `ResourcePath`
115 URI to use to calculate the relative path.
116 walk_up : `bool`, optional
117 Control whether "``..``" can be used to resolve a relative path.
118 Default is `False`. Can not be `True` on Python version 3.11.
120 Returns
121 -------
122 subpath : `str`
123 The sub path of this URI relative to the supplied other URI.
124 Returns `None` if there is no parent child relationship.
125 If this URI is a relative URI but the other is
126 absolute, it is assumed to be in the parent completely unless it
127 starts with ".." (in which case the path is combined and tested).
128 If both URIs are relative, the relative paths are compared
129 for commonality.
131 Notes
132 -----
133 By definition a relative path will be relative to the enclosing
134 absolute parent URI. It will be returned unchanged if it does not
135 use a parent directory specification.
136 """
137 # In some scenarios below a new derived child URI needs to be created
138 # to convert from scheme-less to absolute URI.
139 child = None
141 if not other.isabs():
142 # Both are schemeless relative. Use parent implementation
143 # rather than trying to convert both to file: first since schemes
144 # match.
145 pass
146 elif other.isabs():
147 # Append child to other. This can account for .. in child path.
148 child = other.join(self.path)
149 else:
150 raise RuntimeError(f"Unexpected combination of {child}.relative_to({other}).")
152 if child is None:
153 return super().relative_to(other, walk_up=walk_up)
154 return child.relative_to(other, walk_up=walk_up)
156 @classmethod
157 def _fixupPathUri(
158 cls,
159 parsed: urllib.parse.ParseResult,
160 root: ResourcePath | None = None,
161 forceAbsolute: bool = False,
162 forceDirectory: bool | None = None,
163 ) -> tuple[urllib.parse.ParseResult, bool | None]:
164 """Fix up relative paths for local file system.
166 Parameters
167 ----------
168 parsed : `~urllib.parse.ParseResult`
169 The result from parsing a URI using `urllib.parse`.
170 root : `ResourcePath`, optional
171 Path to use as root when converting relative to absolute.
172 If `None`, it will be the current working directory. Will be
173 ignored if the supplied path is already absolute or if
174 ``forceAbsolute`` is `False`.
175 forceAbsolute : `bool`, optional
176 If `True`, scheme-less relative URI will be converted to an
177 absolute path using a ``file`` scheme. If `False` scheme-less URI
178 will remain scheme-less and will not be updated to ``file`` or
179 absolute path.
180 forceDirectory : `bool`, optional
181 If `True` forces the URI to end with a separator, otherwise given
182 URI is interpreted as is. `False` can be used to indicate that
183 the URI is known to correspond to a file. `None` means that the
184 status is unknown.
186 Returns
187 -------
188 modified : `~urllib.parse.ParseResult`
189 Update result if a URI is being handled.
190 dirLike : `bool`
191 `True` if given parsed URI has a trailing separator or
192 forceDirectory is True. Otherwise `False`.
194 Notes
195 -----
196 Relative paths are explicitly not supported by RFC8089 but `urllib`
197 does accept URIs of the form ``file:relative/path.ext``. They need
198 to be turned into absolute paths before they can be used. This is
199 always done regardless of the ``forceAbsolute`` parameter.
201 Scheme-less paths are normalized and environment variables are
202 expanded.
203 """
204 # assume we are not dealing with a directory URI
205 dirLike = forceDirectory
207 # Replacement values for the URI
208 replacements = {}
210 # this is a local OS file path which can support tilde expansion.
211 # we quoted it in the constructor so unquote here
212 expandedPath = os.path.expanduser(urllib.parse.unquote(parsed.path))
214 # We might also be receiving a path containing environment variables
215 # so expand those here, although we treat $X_DIR at the start of the
216 # path as a special EUPS URI. This allows us to handle EUPS-style
217 # env var specifications even if EUPS has not set them.
218 # Support $X_DIR and ${X_DIR} variants at the start of the path.
219 if eups := re.match(r"(\$\{?([A-Z_]+)_DIR\}?)/", expandedPath):
220 replacements["scheme"] = "eups"
221 # Two matching groups: the entire env var, and the EUPS product.
222 replacements["netloc"] = eups.group(2).lower()
223 expandedPath = expandedPath.removeprefix(eups.group(1))
225 expandedPath = os.path.expandvars(expandedPath)
227 # Ensure that this becomes a file URI if it is already absolute, unless
228 # we already overrode it above.
229 if os.path.isabs(expandedPath):
230 if "scheme" not in replacements:
231 replacements["scheme"] = "file"
232 # Keep in OS form for now to simplify later logic
233 replacements["path"] = os.path.normpath(expandedPath)
234 elif forceAbsolute:
235 # Need to know the root that should be prepended.
236 if root is None:
237 root_str = os.path.abspath(os.path.curdir)
238 else:
239 if root.scheme and root.scheme != "file":
240 raise ValueError(f"The override root must be a file URI not {root.scheme}")
241 # os.path does not care whether something is dirLike or not
242 # so we trust the user.
243 root_str = os.path.abspath(root.ospath)
245 # Convert to "file" scheme to make it consistent with the above
246 # decision. It makes no sense for sometimes an absolute path
247 # to be a file URI and sometimes for it not to be.
248 replacements["scheme"] = "file"
250 # Keep in OS form for now.
251 replacements["path"] = os.path.normpath(os.path.join(root_str, expandedPath))
252 else:
253 # No change needed for relative local path staying relative
254 # except normalization
255 replacements["path"] = os.path.normpath(expandedPath)
256 # normalization of empty path returns "." so we are dirLike
257 if expandedPath == "":
258 dirLike = True
260 # normpath strips trailing "/" which makes it hard to keep
261 # track of directory vs file when calling replaceFile
263 # add the trailing separator only if explicitly required or
264 # if it was stripped by normpath. Acknowledge that trailing
265 # separator exists.
266 endsOnSep = expandedPath.endswith(os.sep) and not replacements["path"].endswith(os.sep)
268 # Consistency check.
269 if forceDirectory is False and endsOnSep:
270 raise ValueError(
271 f"URI {parsed.geturl()} ends with {os.sep} but "
272 "forceDirectory parameter declares it to be a file."
273 )
275 if forceDirectory or endsOnSep or dirLike:
276 dirLike = True
277 if not replacements["path"].endswith(os.sep):
278 replacements["path"] += os.sep
280 if "scheme" in replacements and replacements["scheme"] == "file":
281 # This is now meant to be a URI path so force to posix
282 # and quote. EUPS URIs are not quoted.
283 replacements["path"] = urllib.parse.quote(os2posix(replacements["path"]))
285 # ParseResult is a NamedTuple so _replace is standard API
286 parsed = parsed._replace(**replacements)
288 # We do allow fragment but do not expect params or query to be
289 # specified for schemeless
290 if parsed.params or parsed.query:
291 log.warning("Additional items unexpectedly encountered in schemeless URI: %s", parsed.geturl())
293 return parsed, dirLike